16 files changed
+5285
-1913
No files matched your search
@@ -0,0 +1,296 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okio.ByteString
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class WsTransportDeviceTest {
|
||||||
|
private class FakeWebSocket(
|
||||||
|
private val request: Request,
|
||||||
|
var accept: Boolean = true,
|
||||||
|
var queueSizeValue: Long = 0L,
|
||||||
|
) : WebSocket {
|
||||||
|
val sentFrames = ConcurrentLinkedQueue<String>()
|
||||||
|
val attempts = AtomicInteger(0)
|
||||||
|
val closedCode = AtomicInteger(-1)
|
||||||
|
val closedReason =
|
||||||
|
java.util.concurrent.atomic
|
||||||
|
.AtomicReference<String?>(null)
|
||||||
|
val canceled = AtomicBoolean(false)
|
||||||
|
|
||||||
|
override fun request(): Request = request
|
||||||
|
|
||||||
|
override fun queueSize(): Long = queueSizeValue
|
||||||
|
|
||||||
|
override fun send(text: String): Boolean {
|
||||||
|
attempts.incrementAndGet()
|
||||||
|
if (!accept) return false
|
||||||
|
sentFrames.add(text)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun send(bytes: ByteString): Boolean {
|
||||||
|
attempts.incrementAndGet()
|
||||||
|
if (!accept) return false
|
||||||
|
sentFrames.add(bytes.utf8())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close(
|
||||||
|
code: Int,
|
||||||
|
reason: String?,
|
||||||
|
): Boolean {
|
||||||
|
closedCode.set(code)
|
||||||
|
closedReason.set(reason)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
canceled.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestFixture {
|
||||||
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
val lock = Any()
|
||||||
|
val clock = AtomicLong(100L)
|
||||||
|
val dummyRequest: Request = Request.Builder().url("http://localhost/ws").build()
|
||||||
|
val openResponse: Response =
|
||||||
|
Response
|
||||||
|
.Builder()
|
||||||
|
.request(dummyRequest)
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.code(101)
|
||||||
|
.message("Switching Protocols")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordedEvents = ConcurrentLinkedQueue<WsEvent>()
|
||||||
|
lateinit var transport: WsTransport
|
||||||
|
lateinit var rpc: RpcChannel
|
||||||
|
lateinit var health: ConnectionHealth
|
||||||
|
lateinit var replay: ReplayTracker
|
||||||
|
|
||||||
|
init {
|
||||||
|
health =
|
||||||
|
ConnectionHealth(
|
||||||
|
scope = scope,
|
||||||
|
isConnected = { transport.isConnected },
|
||||||
|
pingRequest = { _ -> },
|
||||||
|
cancelSocket = { transport.cancelSocket() },
|
||||||
|
pendingReply = { transport.pendingReply },
|
||||||
|
nowMs = { clock.get() },
|
||||||
|
)
|
||||||
|
|
||||||
|
replay =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = scope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { recordedEvents.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
rpc =
|
||||||
|
RpcChannel(
|
||||||
|
scope = scope,
|
||||||
|
json = json,
|
||||||
|
outboundLock = lock,
|
||||||
|
sendRequest = { method, params, onSent ->
|
||||||
|
transport.send(rpc.createRequest(method, params), onSent)
|
||||||
|
},
|
||||||
|
sendFrame = { transport.sendDirectFrame(it) },
|
||||||
|
removeQueuedMessage = { transport.removeQueuedMessage(it) },
|
||||||
|
onIdleCheck = { transport.disconnectIfIdleInBackground() },
|
||||||
|
onResult = { transport.onRpcResult(it) },
|
||||||
|
onError = { transport.onRpcError(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
transport =
|
||||||
|
WsTransport(
|
||||||
|
scope = scope,
|
||||||
|
outboundLock = lock,
|
||||||
|
reconnectPolicy = ReconnectPolicy(),
|
||||||
|
connectionHealth = health,
|
||||||
|
replayTracker = replay,
|
||||||
|
rpcChannel = rpc,
|
||||||
|
onRawMessage = { raw ->
|
||||||
|
rpc.handleIncomingFrame(raw, replay) { recordedEvents.add(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
try {
|
||||||
|
transport.disconnect(clearPendingMessages = true)
|
||||||
|
} finally {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun transportRpcRoundTripWithStaleListenerIsolation() =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val fixture = TestFixture()
|
||||||
|
try {
|
||||||
|
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
|
val listenerGen1 = fixture.transport.createListenerForTest(1)
|
||||||
|
listenerGen1.onOpen(fakeWs, fixture.openResponse)
|
||||||
|
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
assertEquals(fakeWs, fixture.transport.webSocketForTest)
|
||||||
|
|
||||||
|
val deferred = fixture.rpc.request("gateway.ping", emptyMap())
|
||||||
|
val sentFrame = requireNotNull(fakeWs.sentFrames.poll())
|
||||||
|
|
||||||
|
val parsedSent = fixture.json.decodeFromString<JsonRpcRequest>(sentFrame)
|
||||||
|
val reqId = parsedSent.id
|
||||||
|
|
||||||
|
fixture.clock.set(200L)
|
||||||
|
val responseJson = """{"jsonrpc":"2.0","id":"$reqId","result":42.0}"""
|
||||||
|
listenerGen1.onMessage(fakeWs, responseJson)
|
||||||
|
|
||||||
|
val result = withTimeout(2_000L) { deferred.await() }
|
||||||
|
assertEquals(JsonPrimitive(42.0), result)
|
||||||
|
|
||||||
|
val event = requireNotNull(fixture.recordedEvents.poll())
|
||||||
|
assertTrue(event is WsEvent.RpcResult)
|
||||||
|
assertEquals(reqId, (event as WsEvent.RpcResult).id)
|
||||||
|
assertEquals(200L, fixture.health.lastPongTimestamp)
|
||||||
|
|
||||||
|
val staleListener = fixture.transport.createListenerForTest(0)
|
||||||
|
val staleWs = FakeWebSocket(fixture.dummyRequest, accept = true)
|
||||||
|
staleListener.onMessage(staleWs, """{"jsonrpc":"2.0","id":999,"result":99.0}""")
|
||||||
|
listenerGen1.onMessage(staleWs, """{"jsonrpc":"2.0","id":998,"result":88.0}""")
|
||||||
|
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
assertEquals(fakeWs, fixture.transport.webSocketForTest)
|
||||||
|
assertEquals(200L, fixture.health.lastPongTimestamp)
|
||||||
|
assertTrue(fixture.recordedEvents.isEmpty())
|
||||||
|
} finally {
|
||||||
|
fixture.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectingWsQueuesAndFlushesOnNextAcceptSocket() =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val fixture = TestFixture()
|
||||||
|
try {
|
||||||
|
val rejectingWs = FakeWebSocket(fixture.dummyRequest, accept = false, queueSizeValue = 1L)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
|
val listener1 = fixture.transport.createListenerForTest(1)
|
||||||
|
listener1.onOpen(rejectingWs, fixture.openResponse)
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
var recordedId: String? = null
|
||||||
|
var callbackRanUnderLock = false
|
||||||
|
val req1 = fixture.rpc.createRequest("test.one", emptyMap())
|
||||||
|
val returnedId =
|
||||||
|
fixture.transport.send(
|
||||||
|
request = req1,
|
||||||
|
onSent = { id ->
|
||||||
|
callbackRanUnderLock = Thread.holdsLock(fixture.lock)
|
||||||
|
recordedId = id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(returnedId, req1.id.toString())
|
||||||
|
assertEquals(returnedId, recordedId)
|
||||||
|
assertTrue(callbackRanUnderLock)
|
||||||
|
|
||||||
|
val req2 = fixture.rpc.createRequest("test.two", emptyMap())
|
||||||
|
fixture.transport.messageQueueForTest.add(fixture.rpc.encodeRequest(req2))
|
||||||
|
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
|
||||||
|
assertNotNull(fixture.transport.outboundDrainJobForTest)
|
||||||
|
assertEquals(2, fixture.transport.messageQueueForTest.size)
|
||||||
|
|
||||||
|
val acceptWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||||
|
val listenerReplacement = fixture.transport.createListenerForTest(1)
|
||||||
|
listenerReplacement.onOpen(acceptWs, fixture.openResponse)
|
||||||
|
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.CONNECTED, fixture.transport.connectionStatus.value)
|
||||||
|
assertNull(fixture.transport.outboundDrainJobForTest)
|
||||||
|
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
val frameList = acceptWs.sentFrames.toList()
|
||||||
|
assertEquals(
|
||||||
|
listOf(fixture.rpc.encodeRequest(req1), fixture.rpc.encodeRequest(req2)),
|
||||||
|
frameList,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
fixture.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun authClosingDoesNotPrematurelyDropPendingRpcAndClosedHaltsReconnect() =
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val fixture = TestFixture()
|
||||||
|
try {
|
||||||
|
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
|
val listener = fixture.transport.createListenerForTest(1)
|
||||||
|
listener.onOpen(fakeWs, fixture.openResponse)
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
val pendingDeferred = fixture.rpc.request("gateway.ping", emptyMap())
|
||||||
|
assertTrue(fixture.rpc.hasPendingCalls())
|
||||||
|
|
||||||
|
listener.onClosing(fakeWs, 4401, "AUTH_EXPIRED")
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
assertTrue(fixture.rpc.hasPendingCalls())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
listener.onClosed(fakeWs, 4401, "AUTH_EXPIRED")
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertNull(fixture.transport.reconnectJobForTest)
|
||||||
|
assertTrue(fixture.rpc.hasPendingCalls())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
fixture.transport.disconnect(clearPendingMessages = true)
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
pendingDeferred.cancel()
|
||||||
|
} finally {
|
||||||
|
fixture.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal class ConnectionHealth(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val isConnected: () -> Boolean,
|
||||||
|
private val pingRequest: suspend (Long) -> Unit,
|
||||||
|
private val cancelSocket: () -> Unit,
|
||||||
|
private val pendingReply: () -> Boolean,
|
||||||
|
private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L },
|
||||||
|
private val log: (String) -> Unit = {},
|
||||||
|
private val debugLog: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
internal const val HEARTBEAT_INTERVAL_MS = 15_000L
|
||||||
|
internal const val STALE_THRESHOLD_MS = 30_000L
|
||||||
|
internal const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lastPongTimestamp: Long = 0L
|
||||||
|
internal set
|
||||||
|
|
||||||
|
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
||||||
|
val lastLatencyMs: StateFlow<Long?> = _lastLatencyMs.asStateFlow()
|
||||||
|
|
||||||
|
val isHealthy: Boolean
|
||||||
|
get() = isConnected() && (nowMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
|
||||||
|
|
||||||
|
private var healthJob: Job? = null
|
||||||
|
private var foregroundProbeJob: Job? = null
|
||||||
|
private var transportProbeJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
healthJob?.cancel()
|
||||||
|
lastPongTimestamp = nowMs()
|
||||||
|
healthJob =
|
||||||
|
scope.launch {
|
||||||
|
while (isConnected()) {
|
||||||
|
delay(HEARTBEAT_INTERVAL_MS)
|
||||||
|
if (isConnected()) {
|
||||||
|
try {
|
||||||
|
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log("Health check ping failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!runHealthCheckPass()) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
healthJob?.cancel()
|
||||||
|
healthJob = null
|
||||||
|
_lastLatencyMs.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInboundFrame() {
|
||||||
|
lastPongTimestamp = nowMs()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
|
||||||
|
val start = nowMs()
|
||||||
|
pingRequest(timeoutMs)
|
||||||
|
val latency = (nowMs() - start).coerceAtLeast(0L)
|
||||||
|
lastPongTimestamp = nowMs()
|
||||||
|
_lastLatencyMs.value = latency
|
||||||
|
return latency
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setLastLatencyForTest(latency: Long?) {
|
||||||
|
_lastLatencyMs.value = latency
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runHealthCheckPass(): Boolean {
|
||||||
|
if (!isConnected()) return false
|
||||||
|
val staleMs = nowMs() - lastPongTimestamp
|
||||||
|
if (staleMs <= STALE_THRESHOLD_MS) return true
|
||||||
|
log("WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
|
||||||
|
cancelSocket()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun forceHealthCheckForTest(staleMillis: Long) {
|
||||||
|
lastPongTimestamp = nowMs() - staleMillis
|
||||||
|
runHealthCheckPass()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun probeLivenessOnWake(deferredOnce: Boolean = false) {
|
||||||
|
if (!isConnected()) return
|
||||||
|
foregroundProbeJob?.cancel()
|
||||||
|
foregroundProbeJob =
|
||||||
|
scope.launch {
|
||||||
|
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
|
||||||
|
if (alive) return@launch
|
||||||
|
if (pendingReply() && !deferredOnce) {
|
||||||
|
delay(3_000L)
|
||||||
|
probeLivenessOnWake(deferredOnce = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
log("Foreground liveness probe failed — cancelling socket")
|
||||||
|
cancelSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelForegroundProbe() {
|
||||||
|
foregroundProbeJob?.cancel()
|
||||||
|
foregroundProbeJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun probeLivenessOnTransportChange(
|
||||||
|
timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS,
|
||||||
|
onFailureAction: () -> Unit = cancelSocket,
|
||||||
|
) {
|
||||||
|
if (!isConnected()) return
|
||||||
|
transportProbeJob?.cancel()
|
||||||
|
transportProbeJob =
|
||||||
|
scope.launch {
|
||||||
|
debugLog("Network transport changed — probing WebSocket liveness")
|
||||||
|
val alive = runCatching { ping(timeoutMs) }.isSuccess
|
||||||
|
if (alive) {
|
||||||
|
debugLog("WebSocket liveness probe succeeded on new transport")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
log("Transport change liveness probe failed — cancelling socket")
|
||||||
|
onFailureAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
internal class ReconnectPolicy(
|
||||||
|
private val random: () -> Double = { Math.random() },
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
var currentBackoff: Long = INITIAL_BACKOFF_MS
|
||||||
|
private var initialBackoffMs: Long = INITIAL_BACKOFF_MS
|
||||||
|
private var testBackoffOverrideMs: Long? = null
|
||||||
|
|
||||||
|
fun nextDelayMs(): Long {
|
||||||
|
val override = testBackoffOverrideMs
|
||||||
|
if (override != null) return override
|
||||||
|
val ceiling = currentBackoff
|
||||||
|
if (ceiling <= 0L) return 0L
|
||||||
|
return (random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun advance() {
|
||||||
|
currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
currentBackoff = initialBackoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBackoffForTest(initialMillis: Long) {
|
||||||
|
initialBackoffMs = initialMillis
|
||||||
|
currentBackoff = initialMillis
|
||||||
|
testBackoffOverrideMs = initialMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||||
|
private const val BACKOFF_BASE_MS = 300L
|
||||||
|
private const val MAX_BACKOFF_MS = 30_000L
|
||||||
|
private const val BACKOFF_MULTIPLIER = 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks session event sequence watermarks and orchestrates event replay on reconnect.
|
||||||
|
*
|
||||||
|
* Extracted from [HermesWsClient] (issue #1016, issue #1376).
|
||||||
|
*/
|
||||||
|
internal class ReplayTracker(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val fetchEventsSince: suspend (SessionEventsSinceParams) -> SessionEventsSinceResult,
|
||||||
|
private val emitEvent: (WsEvent) -> Unit,
|
||||||
|
private val resolveStoredSessionId: (String?) -> String? = { it },
|
||||||
|
private val onReplayFailure: (String, Exception) -> Unit = { _, _ -> },
|
||||||
|
) {
|
||||||
|
private val lastSeenSeq = ConcurrentHashMap<String, Int>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayEpoch: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayInFlight: Boolean = false
|
||||||
|
|
||||||
|
private val replayHold = ConcurrentHashMap<String, MutableList<Pair<Int?, WsEvent>>>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var replayJob: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously arms the hold buffers for all known sessions and launches replay in [scope].
|
||||||
|
* Arming synchronously ensures racing live frames are held instead of advancing watermarks.
|
||||||
|
*/
|
||||||
|
fun triggerReplay() {
|
||||||
|
if (lastSeenSeq.isEmpty() || replayInFlight) return
|
||||||
|
replayInFlight = true
|
||||||
|
val sessionsToReplay = lastSeenSeq.keys().toList()
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
||||||
|
}
|
||||||
|
replayJob?.cancel()
|
||||||
|
replayJob =
|
||||||
|
scope.launch {
|
||||||
|
fetchReplay(sessionsToReplay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates replay epoch from gateway.ready.
|
||||||
|
* When [epoch] is non-empty and changes from a previous non-null value, resets sequence watermarks.
|
||||||
|
*/
|
||||||
|
fun updateEpoch(epoch: String?) {
|
||||||
|
if (!epoch.isNullOrEmpty()) {
|
||||||
|
if (replayEpoch != null && replayEpoch != epoch) {
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
}
|
||||||
|
replayEpoch = epoch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an incoming event is a duplicate against the current watermark for [sessionId].
|
||||||
|
* Returns true if [seq] <= watermark.
|
||||||
|
*/
|
||||||
|
fun isDuplicate(
|
||||||
|
sessionId: String,
|
||||||
|
seq: Int,
|
||||||
|
): Boolean {
|
||||||
|
val prev = lastSeenSeq[sessionId] ?: 0
|
||||||
|
return seq <= prev
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an incoming live event with sequence tracking.
|
||||||
|
* Evaluates [eventFactory] only after checking for duplicates.
|
||||||
|
* If duplicate, drops it without calling [eventFactory].
|
||||||
|
* If replay is in flight for [sessionId], buffers it in replayHold.
|
||||||
|
* Otherwise advances watermark and emits the event.
|
||||||
|
*/
|
||||||
|
fun acceptLiveEvent(
|
||||||
|
sessionId: String,
|
||||||
|
seq: Int,
|
||||||
|
eventFactory: () -> WsEvent,
|
||||||
|
) {
|
||||||
|
if (isDuplicate(sessionId, seq)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val finalEvent = decorateEvent(eventFactory())
|
||||||
|
if (replayInFlight && replayHold.containsKey(sessionId)) {
|
||||||
|
replayHold[sessionId]?.add(seq to finalEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSeenSeq[sessionId] = seq
|
||||||
|
emitEvent(finalEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an incoming live event with sequence tracking.
|
||||||
|
* If duplicate, drops it.
|
||||||
|
* If replay is in flight for [sessionId], buffers it in replayHold.
|
||||||
|
* Otherwise advances watermark and emits the event.
|
||||||
|
*/
|
||||||
|
fun acceptLiveEvent(
|
||||||
|
sessionId: String,
|
||||||
|
seq: Int,
|
||||||
|
event: WsEvent,
|
||||||
|
) {
|
||||||
|
acceptLiveEvent(sessionId, seq) { event }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decorateEvent(event: WsEvent): WsEvent =
|
||||||
|
if (event is WsEvent.MessageComplete) {
|
||||||
|
event.copy(
|
||||||
|
storedSessionId = resolveStoredSessionId(event.sessionId),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
|
||||||
|
try {
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||||
|
try {
|
||||||
|
val res =
|
||||||
|
fetchEventsSince(
|
||||||
|
SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen),
|
||||||
|
)
|
||||||
|
|
||||||
|
val epoch = res.epoch
|
||||||
|
val epochChanged =
|
||||||
|
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
|
||||||
|
val truncated = res.truncated == true
|
||||||
|
val latestSeq = res.latestSeq
|
||||||
|
|
||||||
|
if (epochChanged) replayEpoch = epoch
|
||||||
|
|
||||||
|
if (epochChanged || truncated) {
|
||||||
|
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
|
||||||
|
// Partial replay would silently hole the transcript — fast-forward watermark
|
||||||
|
// and request full transcript resync.
|
||||||
|
if (epochChanged) lastSeenSeq.clear()
|
||||||
|
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
|
||||||
|
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val eventsList = res.events
|
||||||
|
if (eventsList != null) {
|
||||||
|
for (element in eventsList) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val eventMap = (element as? JsonObject)?.toAny() as? Map<String, Any?> ?: continue
|
||||||
|
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
|
||||||
|
val eventSid = (eventMap["session_id"] as? String) ?: sid
|
||||||
|
if (eventSeq != null && eventSid.isNotEmpty()) {
|
||||||
|
val prev = lastSeenSeq[eventSid] ?: 0
|
||||||
|
if (eventSeq <= prev) continue
|
||||||
|
lastSeenSeq[eventSid] = eventSeq
|
||||||
|
}
|
||||||
|
val parsedEvent = EventParser.parseParams(eventMap)
|
||||||
|
val finalEvent = decorateEvent(parsedEvent)
|
||||||
|
emitEvent(finalEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onReplayFailure(sid, e)
|
||||||
|
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
for (sid in sessionsToReplay) {
|
||||||
|
val held = replayHold.remove(sid)
|
||||||
|
if (held != null) {
|
||||||
|
synchronized(held) {
|
||||||
|
for ((heldSeq, heldEvent) in held) {
|
||||||
|
if (heldSeq != null) {
|
||||||
|
val prev = lastSeenSeq[sid] ?: 0
|
||||||
|
if (heldSeq <= prev) continue
|
||||||
|
lastSeenSeq[sid] = heldSeq
|
||||||
|
}
|
||||||
|
emitEvent(heldEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replayInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete teardown matching disconnect (issue #1016).
|
||||||
|
* Cancels any active replay job and clears all holds, watermarks, and epoch.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
replayHold.clear()
|
||||||
|
replayEpoch = null
|
||||||
|
replayJob?.cancel()
|
||||||
|
replayJob = null
|
||||||
|
replayInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun getSeqWatermarks(): Map<String, Int> = HashMap(lastSeenSeq)
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setSeqWatermark(
|
||||||
|
sessionId: String,
|
||||||
|
seq: Int,
|
||||||
|
) {
|
||||||
|
lastSeenSeq[sessionId] = seq
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun clearSeqWatermarks() {
|
||||||
|
lastSeenSeq.clear()
|
||||||
|
replayHold.clear()
|
||||||
|
replayEpoch = null
|
||||||
|
replayInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun isReplayInFlight(): Boolean = replayInFlight
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setReplayEpochForTest(epoch: String?) {
|
||||||
|
replayEpoch = epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal suspend fun fetchReplayForTest() {
|
||||||
|
fetchReplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket-independent JSON-RPC 2.0 request correlation and response dispatcher.
|
||||||
|
* Manages request IDs, in-flight pending calls, timeouts, cancellation cleanup,
|
||||||
|
* and server response frame formatting.
|
||||||
|
*/
|
||||||
|
internal class RpcChannel(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val json: Json,
|
||||||
|
private val sendRequest: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> String,
|
||||||
|
private val sendFrame: (String) -> Boolean,
|
||||||
|
private val removeQueuedMessage: (String) -> Unit,
|
||||||
|
private val onIdleCheck: () -> Unit,
|
||||||
|
private val logRejected: (method: String, id: String) -> Unit = { _, _ -> },
|
||||||
|
private val outboundLock: Any = Any(),
|
||||||
|
private val onResult: (String) -> Unit = {},
|
||||||
|
private val onError: (String) -> Unit = {},
|
||||||
|
private val resolveStoredSessionId: (String?) -> String? = { it },
|
||||||
|
private val logParseFailure: (Exception) -> Unit = {},
|
||||||
|
private val decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
|
||||||
|
) {
|
||||||
|
/** A single in-flight [request] awaiting its RPC result/error. */
|
||||||
|
private data class PendingCall(
|
||||||
|
val method: String,
|
||||||
|
val deferred: CompletableDeferred<Any?>,
|
||||||
|
var timeoutJob: Job? = null,
|
||||||
|
val suppressErrorEvent: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val requestId = AtomicInteger(0)
|
||||||
|
private val pendingCalls = ConcurrentHashMap<String, PendingCall>()
|
||||||
|
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
fun nextId(): String = requestId.incrementAndGet().toString()
|
||||||
|
|
||||||
|
fun hasPendingCalls(): Boolean = pendingCalls.isNotEmpty()
|
||||||
|
|
||||||
|
fun pendingCallIdsForTest(): Set<String> = pendingCalls.keys.toSet()
|
||||||
|
|
||||||
|
fun pendingCallTimeoutForTest(id: String): Job? = pendingCalls[id]?.timeoutJob
|
||||||
|
|
||||||
|
fun isErrorEventSuppressed(id: String): Boolean = pendingCalls[id]?.suppressErrorEvent == true
|
||||||
|
|
||||||
|
fun registerCapabilityRequest(id: String) {
|
||||||
|
capabilityRequestIds.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCapabilityRequests() {
|
||||||
|
capabilityRequestIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeCapabilityResponse(event: WsEvent): Boolean {
|
||||||
|
val id =
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.RpcResult -> event.id
|
||||||
|
is WsEvent.RpcError -> event.id
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
val isCapabilityResponse =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
capabilityRequestIds.remove(id)
|
||||||
|
}
|
||||||
|
if (!isCapabilityResponse) return false
|
||||||
|
|
||||||
|
removeQueuedMessage(id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequest(
|
||||||
|
method: String,
|
||||||
|
params: Map<String, Any>,
|
||||||
|
): JsonRpcRequest {
|
||||||
|
val id = nextId()
|
||||||
|
val decorated = decorateParams(method, params)
|
||||||
|
return JsonRpcRequest(
|
||||||
|
id = id,
|
||||||
|
method = method,
|
||||||
|
params = decorated.mapValues { it.value.toJsonElement() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun encodeRequest(request: JsonRpcRequest): String = json.encodeToString(request)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON-RPC request that expects a result and returns a [CompletableDeferred] for it.
|
||||||
|
*/
|
||||||
|
fun request(
|
||||||
|
method: String,
|
||||||
|
params: Map<String, Any> = emptyMap(),
|
||||||
|
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||||
|
suppressErrorEvent: Boolean = false,
|
||||||
|
): CompletableDeferred<Any?> {
|
||||||
|
val deferred = CompletableDeferred<Any?>()
|
||||||
|
val id =
|
||||||
|
sendRequest(method, params) { reqId ->
|
||||||
|
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||||
|
}
|
||||||
|
deferred.invokeOnCompletion { cause ->
|
||||||
|
if (cause is CancellationException) {
|
||||||
|
pendingCalls.remove(id)?.let { call ->
|
||||||
|
call.timeoutJob?.cancel()
|
||||||
|
removeQueuedMessage(id)
|
||||||
|
onIdleCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Arm the per-request timeout (fires if the server never answers).
|
||||||
|
pendingCalls[id]?.timeoutJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(timeoutMs)
|
||||||
|
resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method"))
|
||||||
|
}
|
||||||
|
return deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-suspending typed twin of [request] for callers that must enqueue inside a
|
||||||
|
* non-suspending owner check and await the raw result themselves.
|
||||||
|
*/
|
||||||
|
fun <P, R> requestTyped(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||||
|
suppressErrorEvent: Boolean = false,
|
||||||
|
): CompletableDeferred<Any?> = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a typed JSON-RPC request and await its deserialized result.
|
||||||
|
*/
|
||||||
|
suspend fun <P, R> call(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||||
|
suppressErrorEvent: Boolean = false,
|
||||||
|
): R = awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent))
|
||||||
|
|
||||||
|
suspend fun <P, R> awaitResult(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
deferred: CompletableDeferred<Any?>,
|
||||||
|
): R {
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
deferred.cancel(e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
val element = result.toJsonElement()
|
||||||
|
return json.decodeFromJsonElement(method.result, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <P, R> encodeParams(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
): JsonObject {
|
||||||
|
val encoded = json.encodeToJsonElement(method.params, params)
|
||||||
|
require(encoded is JsonObject) {
|
||||||
|
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
||||||
|
}
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete (or fail) a single pending call and cancel its timer. */
|
||||||
|
fun resolvePending(
|
||||||
|
id: String,
|
||||||
|
result: Any?,
|
||||||
|
error: JsonRpcError?,
|
||||||
|
) {
|
||||||
|
val call = pendingCalls.remove(id) ?: return
|
||||||
|
removeQueuedMessage(id)
|
||||||
|
call.timeoutJob?.cancel()
|
||||||
|
if (error != null) {
|
||||||
|
call.deferred.completeExceptionally(
|
||||||
|
HermesWsClient.HermesRpcException(
|
||||||
|
message = error.message,
|
||||||
|
code = error.code,
|
||||||
|
data = error.data,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
call.deferred.complete(result)
|
||||||
|
}
|
||||||
|
onIdleCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail and clear every in-flight [request].
|
||||||
|
*/
|
||||||
|
fun rejectAllPending(
|
||||||
|
error: HermesWsClient.HermesRpcException =
|
||||||
|
HermesWsClient.HermesRpcException("Connection lost — request cancelled"),
|
||||||
|
) {
|
||||||
|
if (pendingCalls.isEmpty()) return
|
||||||
|
val snapshot = pendingCalls.toList()
|
||||||
|
pendingCalls.clear()
|
||||||
|
for ((id, call) in snapshot) {
|
||||||
|
removeQueuedMessage(id)
|
||||||
|
logRejected(call.method, id)
|
||||||
|
call.timeoutJob?.cancel()
|
||||||
|
call.deferred.completeExceptionally(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer a gateway-originated JSON-RPC request.
|
||||||
|
*/
|
||||||
|
fun respondToServerRequest(
|
||||||
|
id: String,
|
||||||
|
result: JsonElement,
|
||||||
|
): Boolean {
|
||||||
|
if (id.isBlank()) return false
|
||||||
|
val frameJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("id", id)
|
||||||
|
put("result", result)
|
||||||
|
}.toString()
|
||||||
|
return sendFrame(frameJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer a gateway-originated request with a standard JSON-RPC error. */
|
||||||
|
fun respondToServerRequestError(
|
||||||
|
id: String,
|
||||||
|
code: Int,
|
||||||
|
message: String,
|
||||||
|
): Boolean {
|
||||||
|
if (id.isBlank()) return false
|
||||||
|
val frameJson =
|
||||||
|
buildJsonObject {
|
||||||
|
put("jsonrpc", "2.0")
|
||||||
|
put("id", id)
|
||||||
|
put(
|
||||||
|
"error",
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", code)
|
||||||
|
put("message", message)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.toString()
|
||||||
|
return sendFrame(frameJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an incoming WebSocket text frame: JSON decoding, request correlation,
|
||||||
|
* server request dispatch, sequence deduplication via [replayTracker], and event emission.
|
||||||
|
*/
|
||||||
|
fun handleIncomingFrame(
|
||||||
|
text: String,
|
||||||
|
replayTracker: ReplayTracker,
|
||||||
|
emitEvent: (WsEvent) -> Unit,
|
||||||
|
) {
|
||||||
|
val event =
|
||||||
|
try {
|
||||||
|
val rpc = json.decodeFromString<JsonRpcResponse>(text)
|
||||||
|
// Resolve pending request() calls with the RAW JsonElement from
|
||||||
|
// rpc.result, BEFORE EventParser.parse() converts it via toAny().
|
||||||
|
// This preserves integer/float type fidelity needed by callers
|
||||||
|
// that re-deserialize the result into typed data classes.
|
||||||
|
val rpcId = rpc.id
|
||||||
|
if (rpcId != null && rpc.error == null && rpc.result != null) {
|
||||||
|
onResult(rpcId)
|
||||||
|
removeQueuedMessage(rpcId)
|
||||||
|
resolvePending(rpcId, rpc.result, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume/replay responses carry still-open server requests
|
||||||
|
// separately from the event ring. Re-deliver them through the
|
||||||
|
// same generic path before exposing the RPC result.
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val openRequests =
|
||||||
|
(rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List<Map<*, *>>
|
||||||
|
openRequests?.forEach { openRequest ->
|
||||||
|
val openId = openRequest["id"] as? String ?: return@forEach
|
||||||
|
val method = openRequest["method"] as? String ?: return@forEach
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val params = openRequest["params"] as? Map<String, Any?> ?: emptyMap()
|
||||||
|
emitEvent(WsEvent.ServerRequest(openId, method, params, replayed = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rpc.id == null) {
|
||||||
|
// Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
|
||||||
|
val sid = rpc.params?.eventSessionId()
|
||||||
|
val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
|
||||||
|
|
||||||
|
if (!sid.isNullOrBlank() && seq != null) {
|
||||||
|
replayTracker.acceptLiveEvent(sid, seq) {
|
||||||
|
EventParser.parse(rpc, text)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = EventParser.parse(rpc, text)
|
||||||
|
if (parsed is WsEvent.MessageComplete) {
|
||||||
|
parsed.copy(
|
||||||
|
storedSessionId = resolveStoredSessionId(parsed.sessionId),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logParseFailure(e)
|
||||||
|
WsEvent.Unknown(text)
|
||||||
|
}
|
||||||
|
if (consumeCapabilityResponse(event)) return
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.RpcResult -> {
|
||||||
|
onResult(event.id)
|
||||||
|
removeQueuedMessage(event.id)
|
||||||
|
resolvePending(event.id, event.result, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.RpcError -> {
|
||||||
|
onError(event.id)
|
||||||
|
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
|
||||||
|
// Opt-in suppression: the caller already handles the
|
||||||
|
// failure through the CompletableDeferred, so the event
|
||||||
|
// copy would only surface a duplicate UI banner for
|
||||||
|
// optional features (e.g. "subagent.list" on gateways
|
||||||
|
// without the method, issue #1089). Default request()
|
||||||
|
// behavior still emits the event for shared consumers.
|
||||||
|
resolvePending(event.id, null, event.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removeQueuedMessage(event.id)
|
||||||
|
resolvePending(event.id, null, event.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
emitEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,912 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
|
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.await
|
||||||
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.utf8Size
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket lifecycle, authentication, outbound queueing, and network transport management.
|
||||||
|
*
|
||||||
|
* Extracted from [HermesWsClient] (issue #1376).
|
||||||
|
*/
|
||||||
|
internal class WsTransport(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val outboundLock: Any,
|
||||||
|
private val reconnectPolicy: ReconnectPolicy,
|
||||||
|
private val connectionHealth: ConnectionHealth,
|
||||||
|
private val replayTracker: ReplayTracker,
|
||||||
|
private val rpcChannel: RpcChannel,
|
||||||
|
private val onRawMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HermesWsClient"
|
||||||
|
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||||
|
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectionGeneration = AtomicInteger(0)
|
||||||
|
private val connected = AtomicBoolean(false)
|
||||||
|
private val intentionalClose = AtomicBoolean(false)
|
||||||
|
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||||
|
private val appInForeground = AtomicBoolean(true)
|
||||||
|
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||||
|
private val backgroundConnectionLease = AtomicBoolean(false)
|
||||||
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
|
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closingSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var outboundDrainJob: Job? = null
|
||||||
|
|
||||||
|
private val networkCollectorStarted = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
|
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pendingReply: Boolean = false
|
||||||
|
internal set
|
||||||
|
|
||||||
|
private val pendingPromptSubmits = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
val isConnected: Boolean get() = connected.get()
|
||||||
|
|
||||||
|
fun setAppForeground(foreground: Boolean) {
|
||||||
|
appInForeground.set(foreground)
|
||||||
|
if (!foreground) {
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectionHealth.probeLivenessOnWake()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acquireExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acquireBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
|
||||||
|
|
||||||
|
fun disconnectIfIdleInBackground() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!appInForeground.get() &&
|
||||||
|
!externalActivityConnectionLease.get() &&
|
||||||
|
!backgroundConnectionLease.get() &&
|
||||||
|
!pendingReply &&
|
||||||
|
!rpcChannel.hasPendingCalls() &&
|
||||||
|
messageQueue.isEmpty()
|
||||||
|
) {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (networkCollectorStarted.compareAndSet(false, true)) {
|
||||||
|
scope.launch {
|
||||||
|
NetworkMonitor.networkChanges.collect { networkAvailable ->
|
||||||
|
reconnectForNetworkChange(networkAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
NetworkMonitor.transportChanges.collect {
|
||||||
|
connectionHealth.probeLivenessOnTransportChange(
|
||||||
|
onFailureAction = { cancelSocket() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var socketToCancel: WebSocket? = null
|
||||||
|
val shouldOpen =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
acceptQueuedMessages.set(true)
|
||||||
|
if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
socketToCancel = webSocket
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
connected.set(false)
|
||||||
|
connectionHealth.stop()
|
||||||
|
}
|
||||||
|
if (connected.get()) {
|
||||||
|
Log.d(TAG, "Already connected — skipping")
|
||||||
|
return@synchronized false
|
||||||
|
}
|
||||||
|
if (_connectionStatus.value == ConnectionStatus.CONNECTING ||
|
||||||
|
_connectionStatus.value == ConnectionStatus.RECONNECTING
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping")
|
||||||
|
return@synchronized false
|
||||||
|
}
|
||||||
|
// An explicit connect follows successful authentication and is
|
||||||
|
// therefore allowed to leave the terminal AUTH_EXPIRED state.
|
||||||
|
intentionalClose.set(false)
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (socketToCancel != null) {
|
||||||
|
rpcChannel.rejectAllPending()
|
||||||
|
socketToCancel.cancel()
|
||||||
|
}
|
||||||
|
if (shouldOpen) openSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnectForNetworkChange(
|
||||||
|
networkAvailable: Boolean = NetworkMonitor.isConnected.value,
|
||||||
|
openReplacement: () -> Unit = ::openSocket,
|
||||||
|
) {
|
||||||
|
val socketToCancel: WebSocket?
|
||||||
|
val shouldOpen: Boolean
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Default network changed — reconnecting WebSocket")
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
socketToCancel = webSocket
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
connected.set(false)
|
||||||
|
connectionHealth.stop()
|
||||||
|
shouldOpen = networkAvailable && AuthManager.isAutoReconnect()
|
||||||
|
_connectionStatus.value =
|
||||||
|
when {
|
||||||
|
!networkAvailable -> ConnectionStatus.NO_NETWORK
|
||||||
|
shouldOpen -> ConnectionStatus.RECONNECTING
|
||||||
|
else -> ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
rpcChannel.rejectAllPending()
|
||||||
|
}
|
||||||
|
socketToCancel?.cancel()
|
||||||
|
if (shouldOpen) openReplacement()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
|
||||||
|
val isGated =
|
||||||
|
try {
|
||||||
|
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// serverStore not initialized yet (transient early call); treat
|
||||||
|
// as loopback so a stale-token refresh still runs. Log so a real
|
||||||
|
// misconfiguration isn't silently swallowed.
|
||||||
|
Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!isGated) {
|
||||||
|
// The loopback dashboard token is regenerated on every server
|
||||||
|
// restart. Refresh it before each WebSocket handshake so automatic
|
||||||
|
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
|
||||||
|
val token =
|
||||||
|
runCatching {
|
||||||
|
DashboardSessionTokenRefresher.refreshAsync()
|
||||||
|
}.getOrNull()
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (isCurrentGeneration(generation) && token != null) {
|
||||||
|
runCatching { AuthManager.setToken(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticketResult = requestWsTicket()
|
||||||
|
if (!ticketResult.ticket.isNullOrBlank()) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrentGeneration(generation)) return false
|
||||||
|
AuthManager.setToken(ticketResult.ticket)
|
||||||
|
}
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val status =
|
||||||
|
when {
|
||||||
|
ticketResult.exception -> ConnectionStatus.RECONNECTING
|
||||||
|
ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED
|
||||||
|
ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING
|
||||||
|
ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING
|
||||||
|
else -> ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
return handleWsTicketRefreshFailure(status, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mintWsTicket(): String? {
|
||||||
|
val isGated =
|
||||||
|
try {
|
||||||
|
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!isGated) {
|
||||||
|
DashboardSessionTokenRefresher.refreshAsync()
|
||||||
|
return AuthManager.getToken()
|
||||||
|
}
|
||||||
|
return requestWsTicket().ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TicketRequestResult(
|
||||||
|
val ticket: String?,
|
||||||
|
val httpCode: Int?,
|
||||||
|
val exception: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun requestWsTicket(): TicketRequestResult {
|
||||||
|
try {
|
||||||
|
val client = OkHttpProvider.probe
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
|
||||||
|
.post("{}".toRequestBody())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val (code, body) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (CookieManager.isInitialized()) {
|
||||||
|
CookieManager.useStore(CookieManager.cookieJar.currentServer())
|
||||||
|
}
|
||||||
|
client.newCall(request).await().use { resp ->
|
||||||
|
resp.code to resp.body.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code in 200..299) {
|
||||||
|
// 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: unparseable ticket response")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS ticket mint failed: HTTP $code")
|
||||||
|
return TicketRequestResult(null, code)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Include the stack: the exception class alone (e.g.
|
||||||
|
// NetworkOnMainThreadException) can't show WHICH caller on what
|
||||||
|
// thread tripped the mint (main-scope kanban events connect vs
|
||||||
|
// the WS reconnect path).
|
||||||
|
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e)
|
||||||
|
return TicketRequestResult(null, null, exception = true)
|
||||||
|
}
|
||||||
|
return TicketRequestResult(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWsTicketRefreshFailure(
|
||||||
|
status: ConnectionStatus,
|
||||||
|
generation: Int,
|
||||||
|
): Boolean {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrentGeneration(generation)) return false
|
||||||
|
_connectionStatus.value = status
|
||||||
|
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
intentionalClose.set(true)
|
||||||
|
acceptQueuedMessages.set(!clearPendingMessages)
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
connectionHealth.cancelForegroundProbe()
|
||||||
|
connectionHealth.stop()
|
||||||
|
webSocket?.close(1000, "Client closed")
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
if (clearPendingMessages) {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
|
messageQueue.clear()
|
||||||
|
queuedMessagesById.clear()
|
||||||
|
pendingPromptSubmits.clear()
|
||||||
|
pendingReply = false
|
||||||
|
replayTracker.clear()
|
||||||
|
}
|
||||||
|
connected.set(false)
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSocket() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (connected.get()) webSocket?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendDirectFrame(frameJson: String): Boolean =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
val ws = webSocket
|
||||||
|
if (ws == null || !connected.get()) false else ws.send(frameJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(
|
||||||
|
request: JsonRpcRequest,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
queueIfDisconnected: Boolean = true,
|
||||||
|
): String {
|
||||||
|
val id = request.id
|
||||||
|
val method = request.method
|
||||||
|
val json = rpcChannel.encodeRequest(request)
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
|
||||||
|
}
|
||||||
|
var reconnect = false
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||||
|
pendingPromptSubmits.add(id)
|
||||||
|
pendingReply = true
|
||||||
|
}
|
||||||
|
val ws = webSocket
|
||||||
|
if (ws != null && connected.get()) {
|
||||||
|
// Never replay send(true): the server may have executed it even if its response is lost.
|
||||||
|
if (ws.send(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
} else {
|
||||||
|
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||||
|
if (queueIfDisconnected && isRetryableMessage(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
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() && queueIfDisconnected) {
|
||||||
|
if (isRetryableMessage(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
Log.d(TAG, "WS disconnected — queuing message")
|
||||||
|
queueMessage(id, json)
|
||||||
|
reconnect = true
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reconnect) connect()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeQueuedMessage(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
queuedMessagesById.remove(id)?.let(messageQueue::remove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRpcResult(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
pendingPromptSubmits.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRpcError(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) {
|
||||||
|
pendingReply = false
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeEvent(event: WsEvent) {
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.MessageStart,
|
||||||
|
is WsEvent.MessageToken,
|
||||||
|
is WsEvent.ThinkingDelta,
|
||||||
|
is WsEvent.ReasoningDelta,
|
||||||
|
is WsEvent.ToolStart,
|
||||||
|
-> {
|
||||||
|
pendingReply = true
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.MessageComplete -> {
|
||||||
|
pendingReply = false
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
|
scope.launch {
|
||||||
|
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
|
||||||
|
ws.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(
|
||||||
|
sessionId: String,
|
||||||
|
text: String,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
queued: Boolean = false,
|
||||||
|
): String =
|
||||||
|
send(
|
||||||
|
rpcChannel.createRequest(
|
||||||
|
method = RpcMethods.PROMPT_SUBMIT.name,
|
||||||
|
params =
|
||||||
|
PromptSubmitParams(
|
||||||
|
sessionId = sessionId,
|
||||||
|
text = text,
|
||||||
|
queued = queued.takeIf { it },
|
||||||
|
).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) },
|
||||||
|
),
|
||||||
|
onSent = onSent,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sendRedirect(
|
||||||
|
sessionId: String,
|
||||||
|
text: String,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
): String =
|
||||||
|
send(
|
||||||
|
rpcChannel.createRequest(
|
||||||
|
method = RpcMethods.SESSION_REDIRECT.name,
|
||||||
|
params =
|
||||||
|
SessionCorrectionParams(
|
||||||
|
sessionId = sessionId,
|
||||||
|
text = text,
|
||||||
|
).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) },
|
||||||
|
),
|
||||||
|
onSent = onSent,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun openSocket() {
|
||||||
|
val generation =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
}
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
if (!refreshWsTicketIfNeeded(generation)) {
|
||||||
|
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (!isCurrentGeneration(generation)) return@launch
|
||||||
|
val url = AuthManager.wsUrl()
|
||||||
|
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
|
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||||
|
webSocket = newSocket
|
||||||
|
} else {
|
||||||
|
newSocket.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCurrentGeneration(generation: Int): Boolean =
|
||||||
|
connectionGeneration.get() == generation && !intentionalClose.get()
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get() ||
|
||||||
|
_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED ||
|
||||||
|
reconnectJob?.isActive == true
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!AuthManager.isAutoReconnect()) {
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||||
|
_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 reconnectDelay = reconnectPolicy.nextDelayMs()
|
||||||
|
reconnectPolicy.advance()
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
|
||||||
|
|
||||||
|
reconnectJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(reconnectDelay)
|
||||||
|
val owner = currentCoroutineContext()[Job]
|
||||||
|
val shouldOpen =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (reconnectJob !== owner) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
reconnectJob = null
|
||||||
|
!intentionalClose.get() &&
|
||||||
|
!connected.get() &&
|
||||||
|
_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldOpen) openSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTerminalAuthClose(
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
): Boolean =
|
||||||
|
code == 4401 || code == 4403 ||
|
||||||
|
reason.contains("unauthorized", ignoreCase = true) ||
|
||||||
|
reason.startsWith("auth:", ignoreCase = true)
|
||||||
|
|
||||||
|
private inner class WsListenerImpl(
|
||||||
|
private val generation: Int,
|
||||||
|
) : WebSocketListener() {
|
||||||
|
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
|
||||||
|
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
webSocket.close(1000, "Superseded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this@WsTransport.webSocket = webSocket
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
connected.set(true)
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
connectionHealth.start()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val msg = messageQueue.peek() ?: break
|
||||||
|
if (!isRetryableMessage(msg)) {
|
||||||
|
Log.w(TAG, "Dropping oversized queued message")
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||||
|
if (!webSocket.send(msg)) {
|
||||||
|
recoverRejectedSocket(webSocket)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
|
}
|
||||||
|
replayTracker.triggerReplay()
|
||||||
|
}
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||||
|
connectionHealth.onInboundFrame()
|
||||||
|
onRawMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closingSocket = webSocket
|
||||||
|
// Do NOT log [reason] — it may carry server-side context.
|
||||||
|
Log.d(TAG, "WebSocket closing: $code")
|
||||||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
}
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||||
|
// Do NOT log [reason] — it may carry server-side context. The
|
||||||
|
// reason is still inspected internally to detect auth failures.
|
||||||
|
Log.i(TAG, "WebSocket closed: $code")
|
||||||
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
connectionHealth.stop()
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: Response?,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||||
|
// Log the exception class only — [Throwable.message] can leak URLs
|
||||||
|
// or headers. The message is still inspected internally for auth
|
||||||
|
// detection.
|
||||||
|
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||||
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
connectionHealth.stop()
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
val code = response?.code ?: 0
|
||||||
|
if (code == 401 || code == 4401 || code == 4403 ||
|
||||||
|
t.message?.contains("401") == true ||
|
||||||
|
t.message?.contains("4401") == true ||
|
||||||
|
t.message?.contains("4403") == true ||
|
||||||
|
t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||||
|
) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test seams (issue #1376 extraction support) ──────────────────────
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectedForTest: AtomicBoolean
|
||||||
|
get() = connected
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val intentionalCloseForTest: AtomicBoolean
|
||||||
|
get() = intentionalClose
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectionGenerationForTest: AtomicInteger
|
||||||
|
get() = connectionGeneration
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val messageQueueForTest: ConcurrentLinkedQueue<String>
|
||||||
|
get() = messageQueue
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val outboundLockForTest: Any
|
||||||
|
get() = outboundLock
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val acceptQueuedMessagesForTest: AtomicBoolean
|
||||||
|
get() = acceptQueuedMessages
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectionStatusForTest: MutableStateFlow<ConnectionStatus>
|
||||||
|
get() = _connectionStatus
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var webSocketForTest: WebSocket?
|
||||||
|
get() = webSocket
|
||||||
|
set(value) {
|
||||||
|
webSocket = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var currentBackoffForTest: Long
|
||||||
|
get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff }
|
||||||
|
set(value) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
reconnectPolicy.currentBackoff = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var reconnectJobForTest: Job?
|
||||||
|
get() = reconnectJob
|
||||||
|
set(value) {
|
||||||
|
reconnectJob = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var outboundDrainJobForTest: Job?
|
||||||
|
get() = outboundDrainJob
|
||||||
|
set(value) {
|
||||||
|
outboundDrainJob = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
reconnectPolicy.setBackoffForTest(initialMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun scheduleReconnectForTest() {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation)
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
|
||||||
|
_connectionStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeOutgoingFrameLog(
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
paramsKeys: Collection<String>?,
|
||||||
|
byteLength: Int,
|
||||||
|
queued: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val prefix = if (queued) "→ (queued)" else "→"
|
||||||
|
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
|
||||||
|
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeQueuedFrameLog(msg: String): String =
|
||||||
|
try {
|
||||||
|
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
|
||||||
|
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"→ (queued) frame bytes=${msg.length}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeIncomingFrameLog(text: String): String =
|
||||||
|
try {
|
||||||
|
val element = OkHttpProvider.json.parseToJsonElement(text)
|
||||||
|
if (element is JsonObject) {
|
||||||
|
val id = (element["id"] as? JsonPrimitive)?.content
|
||||||
|
val method = (element["method"] as? JsonPrimitive)?.content
|
||||||
|
val hasResult = element.containsKey("result")
|
||||||
|
val resultKeys =
|
||||||
|
(element["result"] as? JsonObject)?.keys?.joinToString(
|
||||||
|
",",
|
||||||
|
prefix = "[",
|
||||||
|
postfix = "]",
|
||||||
|
)
|
||||||
|
val errorObj = element["error"] as? JsonObject
|
||||||
|
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
|
||||||
|
val sb = StringBuilder("← ")
|
||||||
|
if (id != null) sb.append("id=").append(id).append(" ")
|
||||||
|
if (method != null) sb.append("method=").append(method).append(" ")
|
||||||
|
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
|
||||||
|
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
|
||||||
|
sb.append("bytes=").append(text.length).toString()
|
||||||
|
} else {
|
||||||
|
"← non-object frame bytes=${text.length}"
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"← frame bytes=${text.length}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ConnectionHealthTest {
|
||||||
|
private class Fixture(
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
var connected: Boolean = true,
|
||||||
|
var pingDelayMs: Long = 100L,
|
||||||
|
var pingShouldFail: Boolean = false,
|
||||||
|
var pendingReplyState: Boolean = false,
|
||||||
|
var initialTimeMs: Long = 100_000L,
|
||||||
|
) {
|
||||||
|
var currentTimeMs: Long = initialTimeMs
|
||||||
|
val pingCalls = mutableListOf<Long>()
|
||||||
|
val cancelCalls = AtomicInteger(0)
|
||||||
|
val logs = mutableListOf<String>()
|
||||||
|
val debugLogs = mutableListOf<String>()
|
||||||
|
|
||||||
|
val health: ConnectionHealth =
|
||||||
|
ConnectionHealth(
|
||||||
|
scope = scope,
|
||||||
|
isConnected = { connected },
|
||||||
|
pingRequest = { timeoutMs ->
|
||||||
|
pingCalls.add(timeoutMs)
|
||||||
|
if (pingShouldFail) {
|
||||||
|
throw IllegalStateException("Timed out after ${timeoutMs}ms")
|
||||||
|
}
|
||||||
|
if (pingDelayMs > 0) {
|
||||||
|
delay(pingDelayMs)
|
||||||
|
currentTimeMs += pingDelayMs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelSocket = {
|
||||||
|
cancelCalls.incrementAndGet()
|
||||||
|
},
|
||||||
|
pendingReply = { pendingReplyState },
|
||||||
|
nowMs = { currentTimeMs },
|
||||||
|
log = { logs.add(it) },
|
||||||
|
debugLog = { debugLogs.add(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pingClockElapsedAndFlowUpdates() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingDelayMs = 120L,
|
||||||
|
initialTimeMs = 1_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
assertEquals(1_000L, fixture.currentTimeMs)
|
||||||
|
val latency = fixture.health.ping(timeoutMs = 5000L)
|
||||||
|
assertEquals(120L, latency)
|
||||||
|
assertEquals(120L, fixture.health.lastLatencyMs.value)
|
||||||
|
assertEquals(1_120L, fixture.health.lastPongTimestamp)
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pingFailureLeavesPreviousLatencyUntouched() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingDelayMs = 50L,
|
||||||
|
initialTimeMs = 5_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
val firstLatency = fixture.health.ping(timeoutMs = 5000L)
|
||||||
|
assertEquals(50L, firstLatency)
|
||||||
|
assertEquals(50L, fixture.health.lastLatencyMs.value)
|
||||||
|
|
||||||
|
fixture.pingShouldFail = true
|
||||||
|
try {
|
||||||
|
fixture.health.ping(timeoutMs = 2000L)
|
||||||
|
fail("Expected ping failure")
|
||||||
|
} catch (expected: IllegalStateException) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous latency state is preserved
|
||||||
|
assertEquals(50L, fixture.health.lastLatencyMs.value)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stopResetsLatencyToNull() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingDelayMs = 40L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.ping()
|
||||||
|
assertEquals(40L, fixture.health.lastLatencyMs.value)
|
||||||
|
|
||||||
|
fixture.health.stop()
|
||||||
|
assertNull(fixture.health.lastLatencyMs.value)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onInboundFrameUpdatesPongTimestamp() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
initialTimeMs = 10_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
assertEquals(0L, fixture.health.lastPongTimestamp)
|
||||||
|
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
assertEquals(10_000L, fixture.health.lastPongTimestamp)
|
||||||
|
|
||||||
|
fixture.currentTimeMs = 25_000L
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
assertEquals(25_000L, fixture.health.lastPongTimestamp)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isHealthyStrictlyEnforcesStaleThresholdOf30000() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
connected = true,
|
||||||
|
initialTimeMs = 50_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
assertEquals(50_000L, fixture.health.lastPongTimestamp)
|
||||||
|
|
||||||
|
// now - lastPong < 30000 is healthy
|
||||||
|
fixture.currentTimeMs = 50_000L + 29_999L
|
||||||
|
assertTrue(fixture.health.isHealthy)
|
||||||
|
|
||||||
|
// now - lastPong == 30000 is NOT healthy (< 30000 required)
|
||||||
|
fixture.currentTimeMs = 50_000L + 30_000L
|
||||||
|
assertFalse(fixture.health.isHealthy)
|
||||||
|
|
||||||
|
// now - lastPong > 30000 is NOT healthy
|
||||||
|
fixture.currentTimeMs = 50_000L + 30_001L
|
||||||
|
assertFalse(fixture.health.isHealthy)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isHealthyDisconnectedGuard() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
connected = true,
|
||||||
|
initialTimeMs = 10_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
assertTrue(fixture.health.isHealthy)
|
||||||
|
|
||||||
|
fixture.connected = false
|
||||||
|
assertFalse(fixture.health.isHealthy)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forceHealthCheckForTestWatchdogStrictThresholdCancelsSocket() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
connected = true,
|
||||||
|
initialTimeMs = 100_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
|
||||||
|
// Within stale threshold (30000ms), no cancellation
|
||||||
|
fixture.health.forceHealthCheckForTest(30_000L)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Exceeding stale threshold (> 30000ms), cancels socket
|
||||||
|
fixture.health.forceHealthCheckForTest(30_001L)
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun periodicHeartbeatCancelsSocketWhenStaleThresholdExceeded() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
connected = true,
|
||||||
|
pingShouldFail = true,
|
||||||
|
pingDelayMs = 0L,
|
||||||
|
initialTimeMs = 10_000L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.start()
|
||||||
|
runCurrent()
|
||||||
|
fixture.health.onInboundFrame()
|
||||||
|
|
||||||
|
// Advance by heartbeat interval (15000ms), still fresh
|
||||||
|
advanceTimeBy(15_000L)
|
||||||
|
fixture.currentTimeMs += 15_000L
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Advance another heartbeat interval to 30000ms from last inbound frame
|
||||||
|
advanceTimeBy(15_000L)
|
||||||
|
fixture.currentTimeMs += 15_000L
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Now time elapsed is > 30000ms (e.g. 30001ms)
|
||||||
|
fixture.currentTimeMs += 1L
|
||||||
|
advanceTimeBy(15_000L)
|
||||||
|
fixture.currentTimeMs += 15_000L
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setLastLatencyForTestSetsFlowDirectly() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
assertNull(fixture.health.lastLatencyMs.value)
|
||||||
|
fixture.health.setLastLatencyForTest(75L)
|
||||||
|
assertEquals(75L, fixture.health.lastLatencyMs.value)
|
||||||
|
fixture.health.setLastLatencyForTest(null)
|
||||||
|
assertNull(fixture.health.lastLatencyMs.value)
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnTransportChangeSuccess() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingDelayMs = 25L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
var customActionCalled = false
|
||||||
|
fixture.health.probeLivenessOnTransportChange(
|
||||||
|
timeoutMs = 5000L,
|
||||||
|
onFailureAction = { customActionCalled = true },
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
assertFalse(customActionCalled)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnTransportChangeFailureInvokesCustomAction() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
var customActionCalled = false
|
||||||
|
fixture.health.probeLivenessOnTransportChange(
|
||||||
|
timeoutMs = 4000L,
|
||||||
|
onFailureAction = { customActionCalled = true },
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(4000L), fixture.pingCalls)
|
||||||
|
assertTrue(customActionCalled)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnTransportChangeFailureInvokesDefaultCancelSocket() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnTransportChange(
|
||||||
|
timeoutMs = 5000L,
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnWakeIdleCancelsImmediatelyOnFailure() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
pendingReplyState = false,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnWakePendingReplyFirstFailureDefers3000MsAndRetriesOnce() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
pendingReplyState = true,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// First probe fails, but because pendingReply=true, it does not cancel immediately.
|
||||||
|
// It schedules a deferred retry after 3000ms.
|
||||||
|
assertEquals(1, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Advance before 3000ms
|
||||||
|
advanceTimeBy(2999L)
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(1, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Advance to 3000ms - second ping is executed
|
||||||
|
advanceTimeBy(1L)
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(2, fixture.pingCalls.size)
|
||||||
|
// Second probe fails, now it cancels
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnWakePendingReplySecondProbeSucceedsLeavesSocketOpen() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
pendingReplyState = true,
|
||||||
|
pingDelayMs = 10L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// First attempt failed, pending deferred retry in 3000ms
|
||||||
|
assertEquals(1, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Before retry fires, recovery occurs: pings now succeed
|
||||||
|
fixture.pingShouldFail = false
|
||||||
|
|
||||||
|
advanceTimeBy(3000L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(2, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelForegroundProbeCancelsPendingProbe() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
pendingReplyState = true,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(1, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
|
||||||
|
// Cancel the scheduled retry
|
||||||
|
fixture.health.cancelForegroundProbe()
|
||||||
|
|
||||||
|
advanceTimeBy(5000L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// No second probe should have been made, no cancellation
|
||||||
|
assertEquals(1, fixture.pingCalls.size)
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnTransportChangeSuccessLogsAtDebugLevelWithoutWarning() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingDelayMs = 25L,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
|
||||||
|
runCurrent()
|
||||||
|
advanceTimeBy(25L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Network transport changed — probing WebSocket liveness",
|
||||||
|
"WebSocket liveness probe succeeded on new transport",
|
||||||
|
),
|
||||||
|
fixture.debugLogs,
|
||||||
|
)
|
||||||
|
assertTrue(fixture.logs.isEmpty())
|
||||||
|
assertEquals(0, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun probeLivenessOnTransportChangeFailureLogsDebugAndWarningAndCancelsSocket() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
pingShouldFail = true,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Network transport changed — probing WebSocket liveness"),
|
||||||
|
fixture.debugLogs,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Transport change liveness probe failed — cancelling socket"),
|
||||||
|
fixture.logs,
|
||||||
|
)
|
||||||
|
assertEquals(1, fixture.cancelCalls.get())
|
||||||
|
} finally {
|
||||||
|
fixture.health.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+532
@@ -0,0 +1,532 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
|
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characterization test suite capturing existing baseline behaviors and edge cases
|
||||||
|
* of [HermesWsClient] before extracting components in issue #1376.
|
||||||
|
*/
|
||||||
|
class HermesWsClientCharacterizationTest {
|
||||||
|
private lateinit var mockWebServer: MockWebServer
|
||||||
|
private val serverSockets = Collections.synchronizedList(mutableListOf<WebSocket>())
|
||||||
|
private lateinit var serverClosed: CountDownLatch
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
serverSockets.clear()
|
||||||
|
serverClosed = CountDownLatch(1)
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.isLoggable(any(), any()) } returns false
|
||||||
|
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||||
|
every { Log.e(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
|
||||||
|
mockWebServer = MockWebServer()
|
||||||
|
mockWebServer.start()
|
||||||
|
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||||
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
|
every { AuthManager.getSessionCookie() } returns null
|
||||||
|
every { AuthManager.serverStore } returns
|
||||||
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
|
every { it.getLatestState() } returns
|
||||||
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState()
|
||||||
|
}
|
||||||
|
|
||||||
|
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||||
|
|
||||||
|
mockkObject(DashboardSessionTokenRefresher)
|
||||||
|
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||||
|
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||||
|
|
||||||
|
HermesWsClient.connectedForTest.set(false)
|
||||||
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
|
||||||
|
HermesWsClient.messageQueueForTest.clear()
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
|
HermesWsClient.setAppForeground(true)
|
||||||
|
HermesWsClient.acceptQueuedMessagesForTest.set(true)
|
||||||
|
HermesWsClient.clearCredentialWarning()
|
||||||
|
HermesWsClient.clearSeqWatermarks()
|
||||||
|
HermesWsClient.setLastLatencyForTest(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
HermesWsClient.rejectAllPending()
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(3000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (serverSockets.isNotEmpty()) {
|
||||||
|
assertTrue("Server endpoint must finish closing", serverClosed.await(3, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
mockWebServer.shutdown()
|
||||||
|
} finally {
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectClientToServer(onServerOpen: ((WebSocket) -> Unit)? = null): WebSocket {
|
||||||
|
val serverOpenLatch = CountDownLatch(1)
|
||||||
|
var serverWs: WebSocket? = null
|
||||||
|
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
serverClosed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: okhttp3.Response?,
|
||||||
|
) {
|
||||||
|
serverClosed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverSockets.add(webSocket)
|
||||||
|
serverWs = webSocket
|
||||||
|
onServerOpen?.invoke(webSocket)
|
||||||
|
serverOpenLatch.countDown()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
return requireNotNull(serverWs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun credentialWarning_updatedByGatewayReadyAndSessionInfo_retainedOnBlank_clearedExplicitly() {
|
||||||
|
assertNull(HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
HermesWsClient.connectedForTest.set(true)
|
||||||
|
val dummySocket =
|
||||||
|
mockk<WebSocket>(relaxed = true) {
|
||||||
|
every { send(any<String>()) } returns true
|
||||||
|
}
|
||||||
|
HermesWsClient.webSocketForTest = dummySocket
|
||||||
|
val generation = HermesWsClient.connectionGenerationForTest.get()
|
||||||
|
val listener = HermesWsClient.createListenerForTest(generation)
|
||||||
|
|
||||||
|
// 1. GatewayReady with non-blank credential_warning updates flow
|
||||||
|
val gatewayReadyFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":"Warning: Auth key expired"}}}"""
|
||||||
|
listener.onMessage(dummySocket, gatewayReadyFrame)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(3000) {
|
||||||
|
HermesWsClient.credentialWarning.first { it == "Warning: Auth key expired" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
// 2. Blank or absent credential_warning does NOT clear the existing value.
|
||||||
|
// Deliver follow-up gateway.ready (absent) after blank and check warning is retained,
|
||||||
|
// then use session.info barrier to prove processing pipeline is drained.
|
||||||
|
val blankFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":""}}}"""
|
||||||
|
listener.onMessage(dummySocket, blankFrame)
|
||||||
|
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
val absentFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"version":"1.0"}}}"""
|
||||||
|
listener.onMessage(dummySocket, absentFrame)
|
||||||
|
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
// Barrier: a distinct non-blank session.info proves all preceding frames were processed in order
|
||||||
|
val barrierFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s-barrier","payload":{"credential_warning":"Barrier reached"}}}"""
|
||||||
|
listener.onMessage(dummySocket, barrierFrame)
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(3000) {
|
||||||
|
HermesWsClient.credentialWarning.first { it == "Barrier reached" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("Barrier reached", HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
// 3. SessionInfo with non-blank updates warning
|
||||||
|
val sessionInfoFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s1","payload":{"credential_warning":"Session warning"}}}"""
|
||||||
|
listener.onMessage(dummySocket, sessionInfoFrame)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(3000) {
|
||||||
|
HermesWsClient.credentialWarning.first { it == "Session warning" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("Session warning", HermesWsClient.credentialWarning.value)
|
||||||
|
|
||||||
|
// 4. Explicit clear resets to null
|
||||||
|
HermesWsClient.clearCredentialWarning()
|
||||||
|
assertNull(HermesWsClient.credentialWarning.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disconnect_resetsLastLatencyMsToNull() {
|
||||||
|
HermesWsClient.setLastLatencyForTest(42L)
|
||||||
|
assertEquals(42L, HermesWsClient.lastLatencyMs.value)
|
||||||
|
|
||||||
|
HermesWsClient.disconnect()
|
||||||
|
assertNull(HermesWsClient.lastLatencyMs.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rpcTimeout_emitsHermesRpcExceptionCodeMinusOneExactMessage() {
|
||||||
|
val serverWs = connectClientToServer()
|
||||||
|
assertNotNull(serverWs)
|
||||||
|
|
||||||
|
val shortTimeoutMs = 150L
|
||||||
|
val deferred = HermesWsClient.request("custom.test_method", emptyMap(), timeoutMs = shortTimeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(3000) {
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Expected deferred to fail with timeout exception")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(-1, e.code)
|
||||||
|
assertEquals("Request timed out: custom.test_method", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectAllPending_failsRequestsWithCustomException_cancelsTimeout_clearsPending() {
|
||||||
|
connectClientToServer()
|
||||||
|
|
||||||
|
val deferred = HermesWsClient.request("custom.hold", emptyMap(), timeoutMs = 10_000L)
|
||||||
|
val pendingIds = HermesWsClient.pendingCallIdsForTest()
|
||||||
|
assertEquals(1, pendingIds.size)
|
||||||
|
val reqId = pendingIds.first()
|
||||||
|
|
||||||
|
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId)
|
||||||
|
assertNotNull("Timeout job should be scheduled", timeoutJob)
|
||||||
|
assertTrue("Timeout job should be active", timeoutJob?.isActive == true)
|
||||||
|
|
||||||
|
val customException = HermesWsClient.HermesRpcException("Custom forced rejection", code = -99)
|
||||||
|
HermesWsClient.rejectAllPending(customException)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"Pending map should be empty after rejectAllPending",
|
||||||
|
HermesWsClient.pendingCallIdsForTest().isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue("Timeout job should be cancelled", timeoutJob?.isCancelled == true)
|
||||||
|
assertTrue("Deferred should be completed exceptionally", deferred.isCompleted)
|
||||||
|
|
||||||
|
try {
|
||||||
|
runBlocking { deferred.await() }
|
||||||
|
fail("Expected custom exception")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(-99, e.code)
|
||||||
|
assertEquals("Custom forced rejection", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconnectForNetworkChange_rejectsPendingCallsImmediately() {
|
||||||
|
connectClientToServer()
|
||||||
|
|
||||||
|
val deferred = HermesWsClient.request("custom.in_flight", emptyMap(), timeoutMs = 30_000L)
|
||||||
|
assertEquals(1, HermesWsClient.pendingCallIdsForTest().size)
|
||||||
|
|
||||||
|
// Network change triggers immediate rejectAllPending
|
||||||
|
HermesWsClient.reconnectForNetworkChange(networkAvailable = false)
|
||||||
|
|
||||||
|
assertTrue("Pending map should be empty after network change", HermesWsClient.pendingCallIdsForTest().isEmpty())
|
||||||
|
assertTrue("Deferred must be completed", deferred.isCompleted)
|
||||||
|
|
||||||
|
try {
|
||||||
|
runBlocking { deferred.await() }
|
||||||
|
fail("Expected pending call to be rejected on network change")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals("Connection lost — request cancelled", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disconnectAndListenerClosed_doNotRejectAllPending_deferredRemainsPendingUntilTimeoutOrReject() {
|
||||||
|
val serverWs = connectClientToServer()
|
||||||
|
|
||||||
|
val shortTimeoutMs = 300L
|
||||||
|
val deferred = HermesWsClient.request("custom.deferred_call", emptyMap(), timeoutMs = shortTimeoutMs)
|
||||||
|
val reqId = HermesWsClient.pendingCallIdsForTest().first()
|
||||||
|
|
||||||
|
// Drive onClosed on the CURRENT active generation before disconnect.
|
||||||
|
// In onClosed: isCurrent() is true (intentionalClose is still false, generation matches).
|
||||||
|
// This exercises the active listener onClosed branch.
|
||||||
|
val activeGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||||
|
val currentListener = HermesWsClient.createListenerForTest(activeGeneration)
|
||||||
|
val activeClientSocket = HermesWsClient.webSocketForTest ?: serverWs
|
||||||
|
|
||||||
|
currentListener.onClosed(activeClientSocket, 1000, "Normal closure")
|
||||||
|
activeClientSocket.cancel()
|
||||||
|
|
||||||
|
// Deferred remains pending after active onClosed
|
||||||
|
assertFalse("Deferred should still remain pending after onClosed", deferred.isCompleted)
|
||||||
|
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
|
||||||
|
|
||||||
|
// Calling disconnect() does NOT invoke rejectAllPending() either
|
||||||
|
HermesWsClient.disconnect()
|
||||||
|
|
||||||
|
assertFalse("Deferred should NOT be completed immediately by disconnect() alone", deferred.isCompleted)
|
||||||
|
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
|
||||||
|
|
||||||
|
// Eventually, the per-request timeout triggers and resolves it
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(2000) {
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Deferred should fail with timeout")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(-1, e.code)
|
||||||
|
assertEquals("Request timed out: custom.deferred_call", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun combinedExternalAndBackgroundLeases_releaseOneKeepsOther_releaseBothDisconnectsWhenBackgrounded() {
|
||||||
|
connectClientToServer()
|
||||||
|
assertTrue(HermesWsClient.isConnected)
|
||||||
|
|
||||||
|
// Acquire both leases
|
||||||
|
HermesWsClient.acquireExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.acquireBackgroundConnectionLease()
|
||||||
|
assertTrue(HermesWsClient.hasBackgroundConnectionLease())
|
||||||
|
|
||||||
|
// Move to background - should NOT disconnect because leases are active
|
||||||
|
HermesWsClient.setAppForeground(false)
|
||||||
|
assertTrue("Should stay connected while leases are active", HermesWsClient.isConnected)
|
||||||
|
|
||||||
|
// Release external lease - background lease still holds connection open
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
assertTrue("Should stay connected with background lease active", HermesWsClient.isConnected)
|
||||||
|
|
||||||
|
// Release background lease - both leases released, app backgrounded, idle -> disconnects
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
|
assertFalse("Should disconnect when all leases released in background", HermesWsClient.isConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delayedReplayAndLiveRace_incomingSeqBufferedBeforeReplayResponseAndDeduped() {
|
||||||
|
val sessionId = "session-characterization-race"
|
||||||
|
val serverOpenLatch = CountDownLatch(1)
|
||||||
|
val replayReceivedLatch = CountDownLatch(1)
|
||||||
|
val allowReplayResponseLatch = CountDownLatch(1)
|
||||||
|
val capturedReplayId = AtomicReference<String>()
|
||||||
|
var serverSocketRef: WebSocket? = null
|
||||||
|
|
||||||
|
// Enqueue MockResponse with WebSocketListener on MockWebServer
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
serverClosed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: okhttp3.Response?,
|
||||||
|
) {
|
||||||
|
serverClosed.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: okhttp3.Response,
|
||||||
|
) {
|
||||||
|
serverSockets.add(webSocket)
|
||||||
|
serverSocketRef = webSocket
|
||||||
|
serverOpenLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) {
|
||||||
|
val request = OkHttpProvider.json.parseToJsonElement(text) as JsonObject
|
||||||
|
val id = (request["id"] as JsonPrimitive).content
|
||||||
|
capturedReplayId.set(id)
|
||||||
|
replayReceivedLatch.countDown()
|
||||||
|
|
||||||
|
if (allowReplayResponseLatch.await(5, TimeUnit.SECONDS)) {
|
||||||
|
val rpcId = requireNotNull(capturedReplayId.get())
|
||||||
|
val idJson = JsonPrimitive(rpcId).toString()
|
||||||
|
val replayResponse =
|
||||||
|
"""{"jsonrpc":"2.0","id":$idJson,"result":{"epoch":"epoch-1","latest_seq":12,"events":[{"type":"message.complete","session_id":"$sessionId","seq":11,"payload":{"text":"Replayed message 11"}},{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Replayed message 12"}}]}}"""
|
||||||
|
webSocket.send(replayResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set watermark at seq 10 and epoch-1 prior to connect
|
||||||
|
HermesWsClient.setSeqWatermark(sessionId, 10)
|
||||||
|
HermesWsClient.setReplayEpochForTest("epoch-1")
|
||||||
|
|
||||||
|
// Prepare subscription with onSubscription latch before stimulus
|
||||||
|
val collectedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
|
||||||
|
val subscriptionLatch = CountDownLatch(1)
|
||||||
|
val collectLatch = CountDownLatch(2)
|
||||||
|
val liveProcessedLatch = CountDownLatch(1)
|
||||||
|
|
||||||
|
val collectorJob =
|
||||||
|
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||||
|
HermesWsClient.events
|
||||||
|
.onSubscription {
|
||||||
|
subscriptionLatch.countDown()
|
||||||
|
}.collect { event ->
|
||||||
|
if (event is WsEvent.SessionInfo && event.data?.get("barrier") == "live-processed") {
|
||||||
|
liveProcessedLatch.countDown()
|
||||||
|
}
|
||||||
|
if (event is WsEvent.MessageComplete && event.sessionId == sessionId) {
|
||||||
|
collectedEvents.add(event)
|
||||||
|
collectLatch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertTrue("Collector subscription should be established", subscriptionLatch.await(3, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
// Connect client - real onOpen automatically arms replay
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) {
|
||||||
|
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
val serverSocket = requireNotNull(serverSocketRef)
|
||||||
|
|
||||||
|
// Wait until the server receives session.events.since request
|
||||||
|
assertTrue("Server should receive replay request", replayReceivedLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
assertTrue("Replay should be in flight", HermesWsClient.isReplayInFlight())
|
||||||
|
|
||||||
|
// 1. Live frame arrived while replay is in flight (seq 12)
|
||||||
|
val liveFrame =
|
||||||
|
"""{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Live message 12"}}}"""
|
||||||
|
serverSocket.send(liveFrame)
|
||||||
|
|
||||||
|
// A following unsequenced frame proves the live notification reached the client
|
||||||
|
// before the replay response is released; unchanged watermark alone is not a barrier.
|
||||||
|
serverSocket.send(
|
||||||
|
"""{"method":"event","params":{"type":"session.info","session_id":"$sessionId","payload":{"barrier":"live-processed"}}}""",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"Live frame must be processed before replay completes",
|
||||||
|
liveProcessedLatch.await(3, TimeUnit.SECONDS),
|
||||||
|
)
|
||||||
|
assertEquals(10, HermesWsClient.getSeqWatermarks()[sessionId])
|
||||||
|
assertEquals("Live frame should be held in replayHold while replay is in flight", 0, collectedEvents.size)
|
||||||
|
|
||||||
|
// 2. Unblock replay response from server
|
||||||
|
allowReplayResponseLatch.countDown()
|
||||||
|
|
||||||
|
// Wait for replay to deliver replayed events and drain held live events
|
||||||
|
assertTrue("Should collect replayed and unheld events", collectLatch.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
// Watermark should advance to 12
|
||||||
|
assertEquals(12, HermesWsClient.getSeqWatermarks()[sessionId])
|
||||||
|
|
||||||
|
// Deduplication: held live frame seq 12 deduped against replay seq 12
|
||||||
|
assertEquals(2, collectedEvents.size)
|
||||||
|
assertEquals("Replayed message 11", (collectedEvents[0] as WsEvent.MessageComplete).text)
|
||||||
|
assertEquals("Replayed message 12", (collectedEvents[1] as WsEvent.MessageComplete).text)
|
||||||
|
} finally {
|
||||||
|
allowReplayResponseLatch.countDown()
|
||||||
|
collectorJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-65
@@ -11,7 +11,6 @@ import io.mockk.mockkStatic
|
|||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.onSubscription
|
import kotlinx.coroutines.flow.onSubscription
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
class HermesWsClientLoggingSecurityTest {
|
class HermesWsClientLoggingSecurityTest {
|
||||||
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
|
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||||
@@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
|
|
||||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||||
|
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
HermesWsClient.connectedForTest.set(false)
|
||||||
connectedField.isAccessible = true
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
|
||||||
intentionalCloseField.isAccessible = true
|
|
||||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
|
||||||
|
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
|
||||||
generationField.isAccessible = true
|
|
||||||
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
|
|
||||||
|
|
||||||
HermesWsClient.rejectAllPending()
|
HermesWsClient.rejectAllPending()
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
@@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
unmockkAll()
|
unmockkAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createWsListener(generation: Int? = null): WebSocketListener {
|
private fun createWsListener(generation: Int? = null): WebSocketListener =
|
||||||
val targetGen =
|
HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get())
|
||||||
generation ?: run {
|
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
|
||||||
generationField.isAccessible = true
|
|
||||||
(generationField.get(HermesWsClient) as AtomicInteger).get()
|
|
||||||
}
|
|
||||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
|
||||||
val constructor = listenerClass.declaredConstructors.single()
|
|
||||||
constructor.isAccessible = true
|
|
||||||
return constructor.newInstance(targetGen) as WebSocketListener
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun attachMockSocket(): WebSocket {
|
private fun attachMockSocket(): WebSocket {
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
every { socket.send(any<String>()) } returns true
|
every { socket.send(any<String>()) } returns true
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, socket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
|
||||||
connectedField.isAccessible = true
|
|
||||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
|
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
|
||||||
intentionalCloseField.isAccessible = true
|
|
||||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
|
||||||
|
|
||||||
return socket
|
return socket
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
|
|
||||||
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
|
|
||||||
field.isAccessible = true
|
|
||||||
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTimeoutJob(pendingCall: Any): Job? {
|
|
||||||
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
|
|
||||||
field.isAccessible = true
|
|
||||||
return field.get(pendingCall) as? Job
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Formatter unit tests ──────────────────────────────────────────────────
|
// ── Formatter unit tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
val listener = createWsListener()
|
val listener = createWsListener()
|
||||||
|
|
||||||
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
|
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
|
||||||
val pendingCalls = getPendingCalls()
|
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||||
val reqId = pendingCalls.keys.firstOrNull()
|
|
||||||
assertNotNull("Pending call ID must exist", reqId)
|
assertNotNull("Pending call ID must exist", reqId)
|
||||||
|
|
||||||
val sensitiveAuthUrl =
|
val sensitiveAuthUrl =
|
||||||
@@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
||||||
|
|
||||||
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
|
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
|
||||||
val pendingCalls = getPendingCalls()
|
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||||
val reqId = pendingCalls.keys.firstOrNull()
|
|
||||||
assertNotNull("Pending call ID must exist", reqId)
|
assertNotNull("Pending call ID must exist", reqId)
|
||||||
|
|
||||||
val errorJson =
|
val errorJson =
|
||||||
@@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
|
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
|
||||||
attachMockSocket()
|
attachMockSocket()
|
||||||
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
|
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
|
||||||
val pendingCalls = getPendingCalls()
|
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||||
val entry =
|
assertNotNull("Pending call must be registered in pendingCalls map", reqId)
|
||||||
pendingCalls.entries.firstOrNull {
|
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!)
|
||||||
it.value.javaClass.name
|
|
||||||
.contains("PendingCall")
|
|
||||||
}
|
|
||||||
assertNotNull("Pending call must be registered in pendingCalls map", entry)
|
|
||||||
val reqId = entry!!.key
|
|
||||||
val timeoutJob = getTimeoutJob(entry.value)
|
|
||||||
assertNotNull("Timeout job must be initialized", timeoutJob)
|
assertNotNull("Timeout job must be initialized", timeoutJob)
|
||||||
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
|
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
|
||||||
|
|
||||||
@@ -451,7 +400,7 @@ class HermesWsClientLoggingSecurityTest {
|
|||||||
assertTrue("Deferred must be cancelled", deferred.isCancelled)
|
assertTrue("Deferred must be cancelled", deferred.isCancelled)
|
||||||
assertFalse(
|
assertFalse(
|
||||||
"Pending call must be removed from pendingCalls map on cancellation",
|
"Pending call must be removed from pendingCalls map on cancellation",
|
||||||
pendingCalls.containsKey(reqId),
|
HermesWsClient.pendingCallIdsForTest().contains(reqId),
|
||||||
)
|
)
|
||||||
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
|
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,27 +91,15 @@ class HermesWsClientTest {
|
|||||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
HermesWsClient.connectedForTest.set(false)
|
||||||
connectedField.isAccessible = true
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
HermesWsClient.messageQueueForTest.clear()
|
||||||
|
|
||||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
|
||||||
statusField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
|
|
||||||
|
|
||||||
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
|
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
HermesWsClient.releaseBackgroundConnectionLease()
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
HermesWsClient.setAppForeground(true)
|
HermesWsClient.setAppForeground(true)
|
||||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
HermesWsClient.acceptQueuedMessagesForTest.set(true)
|
||||||
acceptQueuedMessagesField.isAccessible = true
|
|
||||||
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -350,10 +338,8 @@ class HermesWsClientTest {
|
|||||||
val socket = mockk<WebSocket>()
|
val socket = mockk<WebSocket>()
|
||||||
every { socket.send(any<String>()) } returns true
|
every { socket.send(any<String>()) } returns true
|
||||||
every { socket.close(any(), any()) } returns true
|
every { socket.close(any(), any()) } returns true
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
HermesWsClient.webSocketForTest = socket
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, socket)
|
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
|
||||||
|
|
||||||
val sent =
|
val sent =
|
||||||
HermesWsClient.respondToServerRequest(
|
HermesWsClient.respondToServerRequest(
|
||||||
@@ -384,10 +370,8 @@ class HermesWsClientTest {
|
|||||||
val socket = mockk<WebSocket>()
|
val socket = mockk<WebSocket>()
|
||||||
every { socket.send(any<String>()) } returns true
|
every { socket.send(any<String>()) } returns true
|
||||||
every { socket.close(any(), any()) } returns true
|
every { socket.close(any(), any()) } returns true
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
HermesWsClient.webSocketForTest = socket
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, socket)
|
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
|
||||||
|
|
||||||
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
|
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
|
||||||
verify {
|
verify {
|
||||||
@@ -416,29 +400,14 @@ class HermesWsClientTest {
|
|||||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
every { staleSocket.send(any<String>()) } returns false
|
every { staleSocket.send(any<String>()) } returns false
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = staleSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, staleSocket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED
|
||||||
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"))
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
assertEquals(1, queue.size)
|
assertEquals(1, queue.size)
|
||||||
@@ -446,8 +415,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
|
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
|
||||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true }
|
val outboundLock = HermesWsClient.outboundLockForTest
|
||||||
val outboundLock = lockField.get(HermesWsClient)
|
|
||||||
var callbackHeldLock = false
|
var callbackHeldLock = false
|
||||||
|
|
||||||
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
|
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
|
||||||
@@ -458,25 +426,13 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() {
|
fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() {
|
||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectedForTest.set(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(true)
|
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
every { socket.send(any<String>()) } returns true
|
every { socket.send(any<String>()) } returns true
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
val oldGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||||
socketField.set(HermesWsClient, socket)
|
val staleListener = HermesWsClient.createListenerForTest(oldGeneration)
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
|
||||||
generationField.isAccessible = true
|
|
||||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
|
||||||
val oldGeneration = generation.get()
|
|
||||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
|
||||||
val constructor = listenerClass.declaredConstructors.single()
|
|
||||||
constructor.isAccessible = true
|
|
||||||
val staleListener = constructor.newInstance(oldGeneration) as WebSocketListener
|
|
||||||
var replacementOpens = 0
|
var replacementOpens = 0
|
||||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||||
|
|
||||||
@@ -486,10 +442,10 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
verify(exactly = 1) { socket.cancel() }
|
verify(exactly = 1) { socket.cancel() }
|
||||||
assertEquals(1, replacementOpens)
|
assertEquals(1, replacementOpens)
|
||||||
assertEquals(oldGeneration + 1, generation.get())
|
assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get())
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
||||||
assertEquals(null, socketField.get(HermesWsClient))
|
assertEquals(null, HermesWsClient.webSocketForTest)
|
||||||
assertTrue(deferred.isCompleted)
|
assertTrue(deferred.isCompleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,18 +453,10 @@ class HermesWsClientTest {
|
|||||||
fun testNetworkLossPreservesAuthExpiredStatus() =
|
fun testNetworkLossPreservesAuthExpiredStatus() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
|
||||||
statusField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
|
||||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
|
||||||
socketField.set(HermesWsClient, socket)
|
|
||||||
|
|
||||||
HermesWsClient.reconnectForNetworkChange(false)
|
HermesWsClient.reconnectForNetworkChange(false)
|
||||||
HermesWsClient.reconnectForNetworkChange(true)
|
HermesWsClient.reconnectForNetworkChange(true)
|
||||||
@@ -520,22 +468,13 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() {
|
fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() {
|
||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectedForTest.set(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(true)
|
|
||||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||||
every { oldSocket.send(any<String>()) } returns true
|
every { oldSocket.send(any<String>()) } returns true
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = oldSocket
|
||||||
socketField.isAccessible = true
|
|
||||||
socketField.set(HermesWsClient, oldSocket)
|
|
||||||
HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true))
|
HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true))
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertTrue(queue.isEmpty())
|
assertTrue(queue.isEmpty())
|
||||||
|
|
||||||
HermesWsClient.reconnectForNetworkChange(false)
|
HermesWsClient.reconnectForNetworkChange(false)
|
||||||
@@ -545,13 +484,7 @@ class HermesWsClientTest {
|
|||||||
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
|
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
|
||||||
val replacementSocket = mockk<WebSocket>(relaxed = true)
|
val replacementSocket = mockk<WebSocket>(relaxed = true)
|
||||||
every { replacementSocket.send(any<String>()) } returns true
|
every { replacementSocket.send(any<String>()) } returns true
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||||
generationField.isAccessible = true
|
|
||||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
|
||||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
|
||||||
val constructor = listenerClass.declaredConstructors.single()
|
|
||||||
constructor.isAccessible = true
|
|
||||||
val replacementListener = constructor.newInstance(generation.get()) as WebSocketListener
|
|
||||||
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
|
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
|
||||||
|
|
||||||
assertEquals(1, replacementOpens)
|
assertEquals(1, replacementOpens)
|
||||||
@@ -563,18 +496,10 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testNetworkChangePreservesAuthExpiredSocket() {
|
fun testNetworkChangePreservesAuthExpiredSocket() {
|
||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
|
||||||
statusField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
|
||||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
|
||||||
socketField.set(HermesWsClient, socket)
|
|
||||||
|
|
||||||
HermesWsClient.reconnectForNetworkChange()
|
HermesWsClient.reconnectForNetworkChange()
|
||||||
|
|
||||||
@@ -588,20 +513,13 @@ class HermesWsClientTest {
|
|||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.currentBackoffForTest = 1_000L
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
|
|
||||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
HermesWsClient.scheduleReconnectForTest()
|
||||||
backoffField.isAccessible = true
|
HermesWsClient.scheduleReconnectForTest()
|
||||||
backoffField.setLong(HermesWsClient, 1_000L)
|
|
||||||
|
|
||||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||||
reconnectMethod.isAccessible = true
|
|
||||||
reconnectMethod.invoke(HermesWsClient)
|
|
||||||
reconnectMethod.invoke(HermesWsClient)
|
|
||||||
|
|
||||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -610,35 +528,21 @@ class HermesWsClientTest {
|
|||||||
every { AuthManager.isAutoReconnect() } returns true
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectedForTest.set(false)
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
HermesWsClient.currentBackoffForTest = 0L
|
||||||
|
|
||||||
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 replacementJob = mockk<Job>(relaxed = true)
|
||||||
|
|
||||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
|
val lock = HermesWsClient.outboundLockForTest
|
||||||
lockField.isAccessible = true
|
|
||||||
val lock = lockField.get(HermesWsClient)
|
|
||||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
|
||||||
reconnectMethod.isAccessible = true
|
|
||||||
|
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
reconnectMethod.invoke(HermesWsClient)
|
HermesWsClient.scheduleReconnectForTest()
|
||||||
reconnectJobField.set(HermesWsClient, replacementJob)
|
HermesWsClient.reconnectJobForTest = replacementJob
|
||||||
}
|
}
|
||||||
|
|
||||||
Thread.sleep(100)
|
Thread.sleep(100)
|
||||||
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
|
assertSame(replacementJob, HermesWsClient.reconnectJobForTest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -646,23 +550,15 @@ class HermesWsClientTest {
|
|||||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
every { staleSocket.send(any<String>()) } returns false
|
every { staleSocket.send(any<String>()) } returns false
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = staleSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(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(
|
HermesWsClient.send(
|
||||||
WsMethods.PROMPT_SUBMIT,
|
WsMethods.PROMPT_SUBMIT,
|
||||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertTrue(queue.isEmpty())
|
assertTrue(queue.isEmpty())
|
||||||
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
|
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
|
||||||
}
|
}
|
||||||
@@ -674,10 +570,7 @@ class HermesWsClientTest {
|
|||||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||||
)
|
)
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertTrue(queue.isEmpty())
|
assertTrue(queue.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,24 +580,14 @@ class HermesWsClientTest {
|
|||||||
every { pressuredSocket.send(any<String>()) } returns false
|
every { pressuredSocket.send(any<String>()) } returns false
|
||||||
every { pressuredSocket.queueSize() } returns 1024L
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = pressuredSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, pressuredSocket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
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"))
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||||
drainJobField.isAccessible = true
|
|
||||||
assertNotNull(drainJobField.get(HermesWsClient))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -717,35 +600,18 @@ class HermesWsClientTest {
|
|||||||
every { pressuredSocket.send(any<String>()) } returns false
|
every { pressuredSocket.send(any<String>()) } returns false
|
||||||
every { pressuredSocket.queueSize() } returns 1024L
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = pressuredSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, pressuredSocket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
HermesWsClient.currentBackoffForTest = 1_000L
|
||||||
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"))
|
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 listener = HermesWsClient.createListenerForTest(1)
|
||||||
val constructor = listenerClass.declaredConstructors.single()
|
|
||||||
constructor.isAccessible = true
|
|
||||||
val listener = constructor.newInstance(1) as WebSocketListener
|
|
||||||
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
||||||
|
|
||||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -754,38 +620,22 @@ class HermesWsClientTest {
|
|||||||
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
||||||
every { pressuredSocket.queueSize() } returns 1024L
|
every { pressuredSocket.queueSize() } returns 1024L
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
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\":\"1\"}")
|
||||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
HermesWsClient.webSocketForTest = pressuredSocket
|
||||||
|
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
val listener = HermesWsClient.createListenerForTest(1)
|
||||||
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))
|
listener.onOpen(pressuredSocket, mockk(relaxed = true))
|
||||||
|
|
||||||
assertFalse(HermesWsClient.isConnected)
|
assertFalse(HermesWsClient.isConnected)
|
||||||
assertEquals(1, queue.size)
|
assertEquals(1, queue.size)
|
||||||
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
||||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||||
drainJobField.isAccessible = true
|
|
||||||
assertNotNull(drainJobField.get(HermesWsClient))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -794,29 +644,15 @@ class HermesWsClientTest {
|
|||||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||||
every { currentSocket.send(any<String>()) } returns true
|
every { currentSocket.send(any<String>()) } returns true
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = currentSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
socketField.set(HermesWsClient, currentSocket)
|
HermesWsClient.connectionGenerationForTest.set(2)
|
||||||
|
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
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\"}")
|
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||||
|
|
||||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
val staleListener = HermesWsClient.createListenerForTest(1)
|
||||||
val constructor = listenerClass.declaredConstructors.single()
|
val currentListener = HermesWsClient.createListenerForTest(2)
|
||||||
constructor.isAccessible = true
|
|
||||||
val staleListener = constructor.newInstance(1) as WebSocketListener
|
|
||||||
val currentListener = constructor.newInstance(2) as WebSocketListener
|
|
||||||
val response = mockk<okhttp3.Response>(relaxed = true)
|
val response = mockk<okhttp3.Response>(relaxed = true)
|
||||||
|
|
||||||
staleListener.onOpen(staleSocket, response)
|
staleListener.onOpen(staleSocket, response)
|
||||||
@@ -835,40 +671,24 @@ class HermesWsClientTest {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = staleSocket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, staleSocket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
|
||||||
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"))
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertTrue(queue.isEmpty())
|
assertTrue(queue.isEmpty())
|
||||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
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"))
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertEquals(1, queue.size)
|
assertEquals(1, queue.size)
|
||||||
|
|
||||||
HermesWsClient.disconnect()
|
HermesWsClient.disconnect()
|
||||||
@@ -886,16 +706,11 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
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 deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||||
|
|
||||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
val queue = HermesWsClient.messageQueueForTest
|
||||||
queueField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
|
||||||
assertEquals(1, queue.size)
|
assertEquals(1, queue.size)
|
||||||
|
|
||||||
HermesWsClient.rejectAllPending()
|
HermesWsClient.rejectAllPending()
|
||||||
@@ -910,26 +725,12 @@ class HermesWsClientTest {
|
|||||||
every { socket.send(any<String>()) } returns false
|
every { socket.send(any<String>()) } returns false
|
||||||
every { socket.queueSize() } returns 0L
|
every { socket.queueSize() } returns 0L
|
||||||
|
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
socketField.set(HermesWsClient, socket)
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
|
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
val listener = HermesWsClient.createListenerForTest(1)
|
||||||
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")
|
listener.onClosing(socket, 4401, "expired")
|
||||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||||
@@ -941,21 +742,10 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testSocketFailureDoesNotOverwriteAuthExpired() {
|
fun testSocketFailureDoesNotOverwriteAuthExpired() {
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
val listener = HermesWsClient.createListenerForTest(1)
|
||||||
generationField.isAccessible = true
|
|
||||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
|
||||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
|
||||||
statusField.isAccessible = true
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
|
||||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
|
||||||
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(socket, IOException("network changed"), null)
|
listener.onFailure(socket, IOException("network changed"), null)
|
||||||
|
|
||||||
@@ -965,18 +755,10 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testExplicitConnectRetriesAfterAuthExpired() =
|
fun testExplicitConnectRetriesAfterAuthExpired() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
statusField.isAccessible = true
|
HermesWsClient.connectedForTest.set(true)
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
|
||||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
|
||||||
connectedField.isAccessible = true
|
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
|
||||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = oldSocket
|
||||||
socketField.isAccessible = true
|
|
||||||
socketField.set(HermesWsClient, oldSocket)
|
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
|
|
||||||
HermesWsClient.connect()
|
HermesWsClient.connect()
|
||||||
@@ -1008,17 +790,16 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||||
generationField.isAccessible = true
|
|
||||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
|
||||||
val disconnectedGeneration = generation.get()
|
|
||||||
val replacementConnect = thread { HermesWsClient.connect() }
|
val replacementConnect = thread { HermesWsClient.connect() }
|
||||||
val generationDeadline = System.currentTimeMillis() + 5_000
|
val generationDeadline = System.currentTimeMillis() + 5_000
|
||||||
while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) {
|
while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration &&
|
||||||
|
System.currentTimeMillis() < generationDeadline
|
||||||
|
) {
|
||||||
Thread.sleep(10)
|
Thread.sleep(10)
|
||||||
}
|
}
|
||||||
assertTrue(generation.get() > disconnectedGeneration)
|
assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration)
|
||||||
val currentGeneration = generation.get()
|
val currentGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||||
|
|
||||||
releaseRefresh.countDown()
|
releaseRefresh.countDown()
|
||||||
staleConnect.join(5_000)
|
staleConnect.join(5_000)
|
||||||
@@ -1029,7 +810,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
assertFalse(staleConnect.isAlive)
|
assertFalse(staleConnect.isAlive)
|
||||||
assertFalse(replacementConnect.isAlive)
|
assertFalse(replacementConnect.isAlive)
|
||||||
assertEquals(currentGeneration, generation.get())
|
assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get())
|
||||||
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
|
||||||
verify(exactly = 0) { AuthManager.setToken("stale-token") }
|
verify(exactly = 0) { AuthManager.setToken("stale-token") }
|
||||||
}
|
}
|
||||||
@@ -1441,12 +1222,10 @@ class HermesWsClientTest {
|
|||||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||||
|
|
||||||
// After connect, backoff should be back to initial
|
// After connect, backoff should be back to initial
|
||||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
|
||||||
backoffField.isAccessible = true
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Backoff should reset to initial after successful connect",
|
"Backoff should reset to initial after successful connect",
|
||||||
1000L,
|
1000L,
|
||||||
backoffField.getLong(HermesWsClient),
|
HermesWsClient.currentBackoffForTest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1999,26 +1778,11 @@ class HermesWsClientTest {
|
|||||||
fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() {
|
fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() {
|
||||||
// Issue #1163: exercise the real listener, including the early duplicate return.
|
// Issue #1163: exercise the real listener, including the early duplicate return.
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalClose.isAccessible = true
|
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||||
(intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
|
||||||
generation.isAccessible = true
|
|
||||||
val constructor =
|
|
||||||
Class
|
|
||||||
.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
|
||||||
.declaredConstructors
|
|
||||||
.single()
|
|
||||||
constructor.isAccessible = true
|
|
||||||
val listener =
|
|
||||||
constructor.newInstance(
|
|
||||||
(generation.get(HermesWsClient) as AtomicInteger).get(),
|
|
||||||
) as WebSocketListener
|
|
||||||
// Directly install the reflection-built listener so onMessage runs even
|
// Directly install the reflection-built listener so onMessage runs even
|
||||||
// though this test never performed a real OkHttp connect.
|
// though this test never performed a real OkHttp connect.
|
||||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
HermesWsClient.webSocketForTest = socket
|
||||||
socketField.isAccessible = true
|
|
||||||
socketField.set(HermesWsClient, socket)
|
|
||||||
mockkObject(EventParser)
|
mockkObject(EventParser)
|
||||||
|
|
||||||
val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]")
|
val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]")
|
||||||
@@ -2039,18 +1803,10 @@ class HermesWsClientTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testTerminalCloseCode4403SetsAuthExpired() {
|
fun testTerminalCloseCode4403SetsAuthExpired() {
|
||||||
val socket = mockk<WebSocket>(relaxed = true)
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
HermesWsClient.intentionalCloseForTest.set(false)
|
||||||
intentionalCloseField.isAccessible = true
|
HermesWsClient.connectionGenerationForTest.set(1)
|
||||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
|
|
||||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
val listener = HermesWsClient.createListenerForTest(1)
|
||||||
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, 4403, "forbidden origin")
|
listener.onClosing(socket, 4403, "forbidden origin")
|
||||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
||||||
@@ -2182,9 +1938,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
|
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
HermesWsClient.connectedForTest.set(true)
|
||||||
connectedField.isAccessible = true
|
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
|
||||||
|
|
||||||
val failureLatch = CountDownLatch(1)
|
val failureLatch = CountDownLatch(1)
|
||||||
HermesWsClient.probeLivenessOnTransportChange(
|
HermesWsClient.probeLivenessOnTransportChange(
|
||||||
@@ -2199,9 +1953,7 @@ class HermesWsClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
|
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
|
||||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
HermesWsClient.connectedForTest.set(false)
|
||||||
connectedField.isAccessible = true
|
|
||||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
|
||||||
|
|
||||||
var failureInvoked = false
|
var failureInvoked = false
|
||||||
HermesWsClient.probeLivenessOnTransportChange(
|
HermesWsClient.probeLivenessOnTransportChange(
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Assert.fail
|
import org.junit.Assert.fail
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
@@ -122,13 +121,6 @@ class HermesWsClientTypedCallTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
|
|
||||||
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
|
|
||||||
field.isAccessible = true
|
|
||||||
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun callPutsExactFrameOnTheWire() {
|
fun callPutsExactFrameOnTheWire() {
|
||||||
val opened = CountDownLatch(1)
|
val opened = CountDownLatch(1)
|
||||||
@@ -516,19 +508,25 @@ class HermesWsClientTypedCallTest {
|
|||||||
|
|
||||||
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
|
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
|
||||||
|
|
||||||
val pending = getPendingCalls()
|
assertEquals(
|
||||||
assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size)
|
"Should have exactly 1 pending call while awaiting",
|
||||||
|
1,
|
||||||
|
HermesWsClient.pendingCallIdsForTest().size,
|
||||||
|
)
|
||||||
|
|
||||||
callJob.cancel()
|
callJob.cancel()
|
||||||
|
|
||||||
// Wait briefly for cancellation cleanup to execute
|
// Wait briefly for cancellation cleanup to execute
|
||||||
withTimeout(2000) {
|
withTimeout(2000) {
|
||||||
while (pending.isNotEmpty()) {
|
while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) {
|
||||||
kotlinx.coroutines.delay(20)
|
kotlinx.coroutines.delay(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertTrue("Pending calls should be empty after caller coroutine is cancelled", pending.isEmpty())
|
assertTrue(
|
||||||
|
"Pending calls should be empty after caller coroutine is cancelled",
|
||||||
|
HermesWsClient.pendingCallIdsForTest().isEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ReconnectPolicyTest {
|
||||||
|
@Test
|
||||||
|
fun defaultInitialBackoffIs1000() {
|
||||||
|
val policy = ReconnectPolicy()
|
||||||
|
assertEquals(1000L, policy.currentBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextDelayMsUsesInjectedRandomAndFloorsAt300() {
|
||||||
|
// With random = 0.0, calculated delay is 0, floored to 300 (or ceiling if ceiling < 300)
|
||||||
|
val minPolicy = ReconnectPolicy(random = { 0.0 })
|
||||||
|
assertEquals(300L, minPolicy.nextDelayMs())
|
||||||
|
|
||||||
|
// With random = 0.5 and ceiling = 1000, 0.5 * 1000 = 500
|
||||||
|
val midPolicy = ReconnectPolicy(random = { 0.5 })
|
||||||
|
assertEquals(500L, midPolicy.nextDelayMs())
|
||||||
|
|
||||||
|
// With random nearly 1.0 (0.999), 0.999 * 1000 = 999
|
||||||
|
val maxPolicy = ReconnectPolicy(random = { 0.999 })
|
||||||
|
assertEquals(999L, maxPolicy.nextDelayMs())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextDelayMsRespectsCeilingCapsBelowFloorAndZero() {
|
||||||
|
// Ceiling <= 0 results in 0
|
||||||
|
val zeroPolicy = ReconnectPolicy(random = { 0.5 })
|
||||||
|
zeroPolicy.currentBackoff = 0L
|
||||||
|
assertEquals(0L, zeroPolicy.nextDelayMs())
|
||||||
|
|
||||||
|
val negativePolicy = ReconnectPolicy(random = { 0.5 })
|
||||||
|
negativePolicy.currentBackoff = -100L
|
||||||
|
assertEquals(0L, negativePolicy.nextDelayMs())
|
||||||
|
|
||||||
|
// When ceiling is 100 (which is < 300 floor), delay should be capped to ceiling (100)
|
||||||
|
val lowCeilingPolicy = ReconnectPolicy(random = { 0.0 })
|
||||||
|
lowCeilingPolicy.currentBackoff = 100L
|
||||||
|
assertEquals(100L, lowCeilingPolicy.nextDelayMs())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun advanceDoublesBackoffUpToCapOf30000() {
|
||||||
|
val policy = ReconnectPolicy()
|
||||||
|
assertEquals(1000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(2000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(4000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(8000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(16000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
// Doubles past 30000 should cap exactly at 30000 (not speculative 15000 or 32000)
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(30000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(30000L, policy.currentBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resetRestoresInitialBackoffOf1000() {
|
||||||
|
val policy = ReconnectPolicy()
|
||||||
|
policy.advance()
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(4000L, policy.currentBackoff)
|
||||||
|
|
||||||
|
policy.reset()
|
||||||
|
assertEquals(1000L, policy.currentBackoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setBackoffForTestOverridesCurrentAndInitialBackoffDeterministically() {
|
||||||
|
val policy = ReconnectPolicy(random = { 0.5 })
|
||||||
|
policy.setBackoffForTest(1500L)
|
||||||
|
|
||||||
|
assertEquals(1500L, policy.currentBackoff)
|
||||||
|
// With test override, delay should be deterministic override value
|
||||||
|
assertEquals(1500L, policy.nextDelayMs())
|
||||||
|
|
||||||
|
// advance() still uses deterministic override if set, or advances from set initial/current
|
||||||
|
policy.advance()
|
||||||
|
assertEquals(1500L, policy.nextDelayMs())
|
||||||
|
|
||||||
|
// reset() retains deterministic override behavior or resets to overridden initial
|
||||||
|
policy.reset()
|
||||||
|
assertEquals(1500L, policy.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ReplayTrackerTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `initial state has empty watermarks and no replay in flight`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `watermarks can be set read and cleared without cancelling jobs`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
tracker.setSeqWatermark("s2", 25)
|
||||||
|
|
||||||
|
assertEquals(mapOf("s1" to 10, "s2" to 25), tracker.getSeqWatermarks())
|
||||||
|
|
||||||
|
tracker.clearSeqWatermarks()
|
||||||
|
|
||||||
|
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `triggerReplay is no-op when watermarks are empty`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
var fetchCalled = false
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = {
|
||||||
|
fetchCalled = true
|
||||||
|
SessionEventsSinceResult()
|
||||||
|
},
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.triggerReplay()
|
||||||
|
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
testScope.advanceUntilIdle()
|
||||||
|
assertFalse(fetchCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `triggerReplay arms hold synchronously and sets replay in flight before coroutine runs`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { deferred.await() },
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 5)
|
||||||
|
|
||||||
|
// Arms hold synchronously
|
||||||
|
tracker.triggerReplay()
|
||||||
|
|
||||||
|
assertTrue(tracker.isReplayInFlight())
|
||||||
|
|
||||||
|
// Calling triggerReplay again while in flight is ignored
|
||||||
|
tracker.triggerReplay()
|
||||||
|
assertTrue(tracker.isReplayInFlight())
|
||||||
|
|
||||||
|
// A live event arriving now for s1 must be parked into hold without advancing watermark
|
||||||
|
val liveMsg = WsEvent.MessageToken(token = "live", sessionId = "s1")
|
||||||
|
tracker.acceptLiveEvent("s1", 8, liveMsg)
|
||||||
|
|
||||||
|
// Watermark has NOT advanced to 8 because replay is in flight and event is held
|
||||||
|
assertEquals(5, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertTrue(emitted.isEmpty())
|
||||||
|
|
||||||
|
// Complete replay
|
||||||
|
deferred.complete(SessionEventsSinceResult())
|
||||||
|
testScope.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Replay completed, held event released and emitted, watermark advanced
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
assertEquals(listOf(liveMsg), emitted)
|
||||||
|
assertEquals(8, tracker.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isDuplicate drops equal or older seq and accepts newer seq`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
|
||||||
|
// Same seq: duplicate
|
||||||
|
assertTrue(tracker.isDuplicate("s1", 10))
|
||||||
|
// Older seq: duplicate
|
||||||
|
assertTrue(tracker.isDuplicate("s1", 5))
|
||||||
|
// Newer seq: not duplicate
|
||||||
|
assertFalse(tracker.isDuplicate("s1", 11))
|
||||||
|
// Unknown session defaults to seq 0
|
||||||
|
assertFalse(tracker.isDuplicate("s2", 1))
|
||||||
|
assertTrue(tracker.isDuplicate("s2", 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acceptLiveEvent without in-flight replay emits event and advances watermark`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
val event = WsEvent.MessageToken(token = "tok", sessionId = "s1")
|
||||||
|
|
||||||
|
// Duplicate seq: dropped
|
||||||
|
tracker.acceptLiveEvent("s1", 10, event)
|
||||||
|
assertTrue(emitted.isEmpty())
|
||||||
|
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||||
|
|
||||||
|
// Newer seq: emitted and updates watermark
|
||||||
|
tracker.acceptLiveEvent("s1", 12, event)
|
||||||
|
assertEquals(listOf(event), emitted)
|
||||||
|
assertEquals(12, tracker.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replay events are emitted in provided order and advance watermarks`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val event1 =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"a"}}""",
|
||||||
|
)
|
||||||
|
val event2 =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.token","session_id":"s1","seq":7,"payload":{"text":"b"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
events = listOf(event1, event2),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 5)
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
assertEquals(2, emitted.size)
|
||||||
|
assertEquals("a", (emitted[0] as WsEvent.MessageToken).token)
|
||||||
|
assertEquals("b", (emitted[1] as WsEvent.MessageToken).token)
|
||||||
|
assertEquals(7, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replay events with seq at or below watermark are dropped`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val oldEvent =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.token","session_id":"s1","seq":5,"payload":{"text":"old"}}""",
|
||||||
|
)
|
||||||
|
val newEvent =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"new"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
events = listOf(oldEvent, newEvent),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 5)
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
assertEquals(1, emitted.size)
|
||||||
|
assertEquals("new", (emitted[0] as WsEvent.MessageToken).token)
|
||||||
|
assertEquals(6, tracker.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held live duplicates covered by replay are dropped and fresh held follow`() =
|
||||||
|
runTest {
|
||||||
|
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { deferred.await() },
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 5)
|
||||||
|
tracker.triggerReplay()
|
||||||
|
|
||||||
|
// Live event 6 arrives while replay in flight
|
||||||
|
val live6 = WsEvent.MessageToken(token = "live6", sessionId = "s1")
|
||||||
|
val live7 = WsEvent.MessageToken(token = "live7", sessionId = "s1")
|
||||||
|
tracker.acceptLiveEvent("s1", 6, live6)
|
||||||
|
tracker.acceptLiveEvent("s1", 7, live7)
|
||||||
|
|
||||||
|
// Replay also returns event 6
|
||||||
|
val replay6 =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replay6"}}""",
|
||||||
|
)
|
||||||
|
deferred.complete(
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
events = listOf(replay6),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Replay 6 is emitted first, advancing watermark to 6.
|
||||||
|
// Held live 6 (seq 6 <= 6) is dropped as duplicate.
|
||||||
|
// Held live 7 (seq 7 > 6) is emitted and advances watermark to 7.
|
||||||
|
assertEquals(2, emitted.size)
|
||||||
|
assertEquals("replay6", (emitted[0] as WsEvent.MessageToken).token)
|
||||||
|
assertEquals("live7", (emitted[1] as WsEvent.MessageToken).token)
|
||||||
|
assertEquals(7, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `epoch change clears all watermarks updates epoch and requests transcript resync`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
epoch = "epoch-2",
|
||||||
|
latestSeq = 50,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setReplayEpochForTest("epoch-1")
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
tracker.setSeqWatermark("s2", 20)
|
||||||
|
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
// Watermarks wiped
|
||||||
|
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||||
|
assertEquals(1, emitted.size)
|
||||||
|
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty or null epoch in result does not trigger epoch change`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
epoch = "",
|
||||||
|
events = emptyList(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setReplayEpochForTest("epoch-1")
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertTrue(emitted.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated resync fast-forwards watermark to latestSeq when epoch is same`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
truncated = true,
|
||||||
|
latestSeq = 100,
|
||||||
|
epoch = "epoch-1",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setReplayEpochForTest("epoch-1")
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
// Fast-forwards to latestSeq and emits TranscriptResyncRequired
|
||||||
|
assertEquals(100, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertEquals(listOf(WsEvent.TranscriptResyncRequired("s1")), emitted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replay failure triggers resync invokes onReplayFailure and releases held events`() =
|
||||||
|
runTest {
|
||||||
|
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val failedSessions = mutableListOf<Pair<String, Exception>>()
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
deferred.await()
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
onReplayFailure = { sid, ex -> failedSessions.add(sid to ex) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
tracker.triggerReplay()
|
||||||
|
|
||||||
|
// Park a live event while waiting
|
||||||
|
val liveEvent = WsEvent.MessageToken(token = "held", sessionId = "s1")
|
||||||
|
tracker.acceptLiveEvent("s1", 15, liveEvent)
|
||||||
|
|
||||||
|
// Fail replay
|
||||||
|
val testException = IllegalStateException("network error")
|
||||||
|
deferred.completeExceptionally(testException)
|
||||||
|
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Failure emitted TranscriptResyncRequired, reported to callback, and released held events
|
||||||
|
assertEquals(1, failedSessions.size)
|
||||||
|
assertEquals("s1", failedSessions[0].first)
|
||||||
|
assertTrue(failedSessions[0].second is IllegalStateException)
|
||||||
|
assertEquals("network error", failedSessions[0].second.message)
|
||||||
|
|
||||||
|
assertEquals(2, emitted.size)
|
||||||
|
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
|
||||||
|
assertEquals(liveEvent, emitted[1])
|
||||||
|
assertEquals(15, tracker.getSeqWatermarks()["s1"])
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `MessageComplete event uses resolveStoredSessionId injection`() =
|
||||||
|
runTest {
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val msgCompleteJson =
|
||||||
|
jsonElement(
|
||||||
|
"""{"type":"message.complete","session_id":"wire-sid","payload":{"text":"done"}}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = {
|
||||||
|
SessionEventsSinceResult(
|
||||||
|
events = listOf(msgCompleteJson),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
resolveStoredSessionId = { wireSid -> "resolved-$wireSid" },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("wire-sid", 5)
|
||||||
|
tracker.fetchReplayForTest()
|
||||||
|
|
||||||
|
assertEquals(1, emitted.size)
|
||||||
|
val complete = emitted[0] as WsEvent.MessageComplete
|
||||||
|
assertEquals("done", complete.text)
|
||||||
|
assertEquals("wire-sid", complete.sessionId)
|
||||||
|
assertEquals("resolved-wire-sid", complete.storedSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateEpoch clears watermarks when epoch changes`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setReplayEpochForTest("epoch-1")
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
|
||||||
|
// Same epoch does not clear
|
||||||
|
tracker.updateEpoch("epoch-1")
|
||||||
|
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||||
|
|
||||||
|
// Empty / null does not clear
|
||||||
|
tracker.updateEpoch(null)
|
||||||
|
tracker.updateEpoch("")
|
||||||
|
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||||
|
|
||||||
|
// Different epoch clears watermarks
|
||||||
|
tracker.updateEpoch("epoch-2")
|
||||||
|
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clear cancels in-flight job and resets all state`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { deferred.await() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
tracker.triggerReplay()
|
||||||
|
assertTrue(tracker.isReplayInFlight())
|
||||||
|
|
||||||
|
tracker.clear()
|
||||||
|
|
||||||
|
assertFalse(tracker.isReplayInFlight())
|
||||||
|
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acceptLiveEvent with eventFactory drops duplicate seq without invoking factory or emitting`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
var factoryInvoked = false
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
|
||||||
|
// Duplicate seq <= watermark must not invoke factory nor emit
|
||||||
|
tracker.acceptLiveEvent(sessionId = "s1", seq = 10) {
|
||||||
|
factoryInvoked = true
|
||||||
|
WsEvent.MessageToken(token = "duplicate", sessionId = "s1")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(factoryInvoked)
|
||||||
|
assertTrue(emitted.isEmpty())
|
||||||
|
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `acceptLiveEvent with eventFactory invokes factory once and preserves single-check ordering`() {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
val testScope = TestScope(testDispatcher)
|
||||||
|
val emitted = mutableListOf<WsEvent>()
|
||||||
|
val invocationCount = AtomicInteger(0)
|
||||||
|
val tracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = testScope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { emitted.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker.setSeqWatermark("s1", 10)
|
||||||
|
val expectedEvent = WsEvent.MessageToken(token = "fresh", sessionId = "s1")
|
||||||
|
|
||||||
|
// Factory intentionally injects an interleaved watermark advance (e.g. simulating race or side effect).
|
||||||
|
// Under single-check ordering, the initial pre-parse check (seq 11 > 10) succeeds, calls factory exactly once,
|
||||||
|
// and proceeds to emit and advance watermark without silently dropping due to an unexpected second check.
|
||||||
|
tracker.acceptLiveEvent(sessionId = "s1", seq = 11) {
|
||||||
|
invocationCount.incrementAndGet()
|
||||||
|
tracker.setSeqWatermark("s1", 99)
|
||||||
|
expectedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(1, invocationCount.get())
|
||||||
|
assertEquals(listOf(expectedEvent), emitted)
|
||||||
|
assertEquals(11, tracker.getSeqWatermarks()["s1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class RpcChannelTest {
|
||||||
|
private val testJson =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SampleRequestParams(
|
||||||
|
val message: String,
|
||||||
|
val count: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SampleResult(
|
||||||
|
val status: String,
|
||||||
|
val total: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val sampleMethod =
|
||||||
|
RpcMethod("test.sample", SampleRequestParams.serializer(), SampleResult.serializer())
|
||||||
|
|
||||||
|
private class FakeGateway(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
json: Json,
|
||||||
|
outboundLock: Any = Any(),
|
||||||
|
val onResultCallback: (String) -> Unit = {},
|
||||||
|
val onErrorCallback: (String) -> Unit = {},
|
||||||
|
resolveStoredSessionId: (String?) -> String? = { it },
|
||||||
|
val logParseFailureCallback: (Exception) -> Unit = {},
|
||||||
|
decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
|
||||||
|
) {
|
||||||
|
val sentRequests = mutableListOf<SentRequestRecord>()
|
||||||
|
val sentFrames = mutableListOf<String>()
|
||||||
|
val removedQueuedIds = mutableListOf<String>()
|
||||||
|
val rejectedLogs = mutableListOf<Pair<String, String>>()
|
||||||
|
var idleCheckCount = 0
|
||||||
|
var sendFrameReturnValue = true
|
||||||
|
|
||||||
|
var immediateResolveOnSend: ((id: String, onSent: ((String) -> Unit)?) -> Unit)? = null
|
||||||
|
|
||||||
|
val channel: RpcChannel =
|
||||||
|
RpcChannel(
|
||||||
|
scope = scope,
|
||||||
|
json = json,
|
||||||
|
sendRequest = { method, params, onSent ->
|
||||||
|
val id = "req-${sentRequests.size + 1}"
|
||||||
|
sentRequests.add(SentRequestRecord(id, method, params))
|
||||||
|
onSent?.invoke(id)
|
||||||
|
immediateResolveOnSend?.invoke(id, onSent)
|
||||||
|
id
|
||||||
|
},
|
||||||
|
sendFrame = { frame ->
|
||||||
|
sentFrames.add(frame)
|
||||||
|
sendFrameReturnValue
|
||||||
|
},
|
||||||
|
removeQueuedMessage = { id ->
|
||||||
|
removedQueuedIds.add(id)
|
||||||
|
},
|
||||||
|
onIdleCheck = {
|
||||||
|
idleCheckCount++
|
||||||
|
},
|
||||||
|
logRejected = { method, id ->
|
||||||
|
rejectedLogs.add(method to id)
|
||||||
|
},
|
||||||
|
outboundLock = outboundLock,
|
||||||
|
onResult = onResultCallback,
|
||||||
|
onError = onErrorCallback,
|
||||||
|
resolveStoredSessionId = resolveStoredSessionId,
|
||||||
|
logParseFailure = logParseFailureCallback,
|
||||||
|
decorateParams = decorateParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SentRequestRecord(
|
||||||
|
val id: String,
|
||||||
|
val method: String,
|
||||||
|
val params: Map<String, Any>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nextIdProducesIncreasingNonBlankStrings() {
|
||||||
|
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||||
|
val firstId = gateway.channel.nextId()
|
||||||
|
val secondId = gateway.channel.nextId()
|
||||||
|
|
||||||
|
assertEquals("1", firstId)
|
||||||
|
assertTrue(firstId.isNotBlank())
|
||||||
|
assertTrue(secondId.isNotBlank())
|
||||||
|
val firstNum = firstId.toLong()
|
||||||
|
val secondNum = secondId.toLong()
|
||||||
|
assertTrue(secondNum > firstNum)
|
||||||
|
assertEquals(firstNum + 1L, secondNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestSuccessResolvesDeferredAndCleansUpInOrder() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val deferred = gateway.channel.request("user.ping", mapOf("key" to "value"))
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
assertTrue(gateway.channel.hasPendingCalls())
|
||||||
|
assertTrue(gateway.channel.pendingCallIdsForTest().contains(id))
|
||||||
|
assertNotNull(gateway.channel.pendingCallTimeoutForTest(id))
|
||||||
|
|
||||||
|
val dummyResult = JsonPrimitive("pong")
|
||||||
|
gateway.channel.resolvePending(id, dummyResult, null)
|
||||||
|
|
||||||
|
val resolved = deferred.await()
|
||||||
|
assertEquals(dummyResult, resolved)
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
assertNull(gateway.channel.pendingCallTimeoutForTest(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestErrorResolvesWithHermesRpcExceptionAndErrorData() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val deferred = gateway.channel.request("user.fail")
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
val errorData = buildJsonObject { put("reason", "auth_failed") }
|
||||||
|
val rpcError = JsonRpcError(code = 401, message = "Unauthorized", data = errorData)
|
||||||
|
|
||||||
|
gateway.channel.resolvePending(id, null, rpcError)
|
||||||
|
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
fail("Expected HermesRpcException")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals("Unauthorized", e.message)
|
||||||
|
assertEquals(401, e.code)
|
||||||
|
assertEquals(errorData, e.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun requestTimeoutFailsWithExactMessageAndCodeMinusOne() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val timeoutMs = 5_000L
|
||||||
|
val deferred = gateway.channel.request("agent.turn", timeoutMs = timeoutMs)
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
advanceTimeBy(4_999L)
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(gateway.channel.hasPendingCalls())
|
||||||
|
assertFalse(deferred.isCompleted)
|
||||||
|
|
||||||
|
advanceTimeBy(1L)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertTrue(deferred.isCompleted)
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
fail("Expected HermesRpcException on timeout")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals("Request timed out: agent.turn", e.message)
|
||||||
|
assertEquals(-1, e.code)
|
||||||
|
assertNull(e.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deferredCancellationCancelsTimerRemovesQueuedAndTriggersIdle() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val deferred = gateway.channel.request("slow.method", timeoutMs = 60_000L)
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
val timeoutJob = gateway.channel.pendingCallTimeoutForTest(id)
|
||||||
|
assertNotNull(timeoutJob)
|
||||||
|
assertFalse(timeoutJob!!.isCancelled)
|
||||||
|
|
||||||
|
deferred.cancel()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertTrue(deferred.isCancelled)
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertTrue(timeoutJob.isCancelled)
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
|
||||||
|
// Advancing past original timeout should do nothing further
|
||||||
|
advanceTimeBy(70_000L)
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun suppressionFlagReflectedInIsErrorEventSuppressed() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
gateway.channel.request("normal.call", suppressErrorEvent = false)
|
||||||
|
val normalId = gateway.sentRequests[0].id
|
||||||
|
|
||||||
|
gateway.channel.request("silent.call", suppressErrorEvent = true)
|
||||||
|
val silentId = gateway.sentRequests[1].id
|
||||||
|
|
||||||
|
assertFalse(gateway.channel.isErrorEventSuppressed(normalId))
|
||||||
|
assertTrue(gateway.channel.isErrorEventSuppressed(silentId))
|
||||||
|
assertFalse(gateway.channel.isErrorEventSuppressed("non-existent-id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectAllPendingFailsAllCallsWithProvidedExceptionAndNoIdleCheck() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val deferred1 = gateway.channel.request("method.one")
|
||||||
|
val deferred2 = gateway.channel.request("method.two")
|
||||||
|
val id1 = gateway.sentRequests[0].id
|
||||||
|
val id2 = gateway.sentRequests[1].id
|
||||||
|
|
||||||
|
val customException = HermesWsClient.HermesRpcException("Socket terminated abruptly", code = 1006)
|
||||||
|
|
||||||
|
gateway.channel.rejectAllPending(customException)
|
||||||
|
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
// Verified contract: rejectAllPending performs NO onIdleCheck
|
||||||
|
assertEquals(0, gateway.idleCheckCount)
|
||||||
|
assertEquals(setOf(id1, id2), gateway.removedQueuedIds.toSet())
|
||||||
|
assertEquals(setOf("method.one" to id1, "method.two" to id2), gateway.rejectedLogs.toSet())
|
||||||
|
|
||||||
|
try {
|
||||||
|
deferred1.await()
|
||||||
|
fail("deferred1 should have failed")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertSame(customException, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deferred2.await()
|
||||||
|
fail("deferred2 should have failed")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertSame(customException, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectAllPendingDefaultExceptionMatchesExpectedMessage() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val deferred = gateway.channel.request("some.call")
|
||||||
|
|
||||||
|
gateway.channel.rejectAllPending()
|
||||||
|
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
fail("Expected default exception")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals("Connection lost — request cancelled", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun immediateResolutionDuringOnSentHandledSafely() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
gateway.immediateResolveOnSend = { id, _ ->
|
||||||
|
gateway.channel.resolvePending(id, "instant-result", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val deferred = gateway.channel.request("instant.call")
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
assertTrue(deferred.isCompleted)
|
||||||
|
assertEquals("instant-result", deferred.await())
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typedCallSerializesParamsOmittingDefaultsAndDecodesResult() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
|
||||||
|
val job =
|
||||||
|
async {
|
||||||
|
gateway.channel.call(
|
||||||
|
sampleMethod,
|
||||||
|
SampleRequestParams(message = "hello", count = null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(1, gateway.sentRequests.size)
|
||||||
|
val sent = gateway.sentRequests.first()
|
||||||
|
assertEquals("test.sample", sent.method)
|
||||||
|
// encodeDefaults = false means count (null) is omitted from params
|
||||||
|
val encodedParams =
|
||||||
|
gateway.channel.encodeParams(
|
||||||
|
sampleMethod,
|
||||||
|
SampleRequestParams(message = "hello", count = null),
|
||||||
|
)
|
||||||
|
assertFalse(encodedParams.containsKey("count"))
|
||||||
|
assertTrue(encodedParams.containsKey("message"))
|
||||||
|
|
||||||
|
// Resolve pending with raw json representation
|
||||||
|
val rawResult =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "ok")
|
||||||
|
put("total", 42)
|
||||||
|
put("extra_ignored_key", "bonus")
|
||||||
|
}
|
||||||
|
gateway.channel.resolvePending(sent.id, rawResult, null)
|
||||||
|
|
||||||
|
val result = job.await()
|
||||||
|
assertEquals("ok", result.status)
|
||||||
|
assertEquals(42, result.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typedCallCancellationPropagatesAndCancelsDeferred() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
|
||||||
|
val job =
|
||||||
|
launch {
|
||||||
|
gateway.channel.call(
|
||||||
|
sampleMethod,
|
||||||
|
SampleRequestParams(message = "cancel-me"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
assertTrue(gateway.channel.hasPendingCalls())
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||||
|
assertEquals(1, gateway.idleCheckCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rawJsonNumericFidelityPreservedIntoResolve() =
|
||||||
|
runTest {
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
|
||||||
|
val deferred =
|
||||||
|
gateway.channel.requestTyped(
|
||||||
|
sampleMethod,
|
||||||
|
SampleRequestParams(message = "precision"),
|
||||||
|
)
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
// Map with integer-value double, e.g. 177.0 -> preserves int semantics in toJsonElement
|
||||||
|
val mapResult = mapOf("status" to "precise", "total" to 177.0)
|
||||||
|
gateway.channel.resolvePending(id, mapResult, null)
|
||||||
|
|
||||||
|
val raw = deferred.await()
|
||||||
|
val jsonElement = raw.toJsonElement() as JsonObject
|
||||||
|
assertEquals(177, jsonElement["total"]?.jsonPrimitive?.int)
|
||||||
|
|
||||||
|
// Direct decode with injected channel json to verify round-trip
|
||||||
|
val decoded = testJson.decodeFromJsonElement(sampleMethod.result, jsonElement)
|
||||||
|
assertEquals(177, decoded.total)
|
||||||
|
assertEquals("precise", decoded.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun respondToServerRequestFormatsExactJsonRpcResponseAndPropagatesSink() {
|
||||||
|
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||||
|
val serverReqId = "srv-999"
|
||||||
|
val payload = buildJsonObject { put("pong", true) }
|
||||||
|
|
||||||
|
gateway.sendFrameReturnValue = true
|
||||||
|
val result = gateway.channel.respondToServerRequest(serverReqId, payload)
|
||||||
|
assertTrue(result)
|
||||||
|
assertEquals(1, gateway.sentFrames.size)
|
||||||
|
|
||||||
|
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
|
||||||
|
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(payload, frame["result"])
|
||||||
|
assertFalse(frame.containsKey("method"))
|
||||||
|
assertFalse(frame.containsKey("params"))
|
||||||
|
|
||||||
|
// Blank id returns false immediately without sending frame
|
||||||
|
val blankResult = gateway.channel.respondToServerRequest(" ", payload)
|
||||||
|
assertFalse(blankResult)
|
||||||
|
assertEquals(1, gateway.sentFrames.size)
|
||||||
|
|
||||||
|
// Sink returning false is propagated
|
||||||
|
gateway.sendFrameReturnValue = false
|
||||||
|
val falseResult = gateway.channel.respondToServerRequest("srv-1000", payload)
|
||||||
|
assertFalse(falseResult)
|
||||||
|
assertEquals(2, gateway.sentFrames.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun respondToServerRequestErrorFormatsExactJsonRpcErrorAndPropagatesSink() {
|
||||||
|
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||||
|
val serverReqId = "srv-888"
|
||||||
|
|
||||||
|
gateway.sendFrameReturnValue = true
|
||||||
|
val result = gateway.channel.respondToServerRequestError(serverReqId, -32601, "Method not found")
|
||||||
|
assertTrue(result)
|
||||||
|
assertEquals(1, gateway.sentFrames.size)
|
||||||
|
|
||||||
|
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
|
||||||
|
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
|
||||||
|
assertFalse(frame.containsKey("result"))
|
||||||
|
val errorObj = frame["error"] as JsonObject
|
||||||
|
assertEquals(-32601, errorObj["code"]?.jsonPrimitive?.int)
|
||||||
|
assertEquals("Method not found", errorObj["message"]?.jsonPrimitive?.content)
|
||||||
|
|
||||||
|
// Blank id returns false without sending frame
|
||||||
|
assertFalse(gateway.channel.respondToServerRequestError("", -1, "Bad"))
|
||||||
|
assertEquals(1, gateway.sentFrames.size)
|
||||||
|
|
||||||
|
// Sink returning false is propagated
|
||||||
|
gateway.sendFrameReturnValue = false
|
||||||
|
val falseResult = gateway.channel.respondToServerRequestError("srv-889", -32000, "Error")
|
||||||
|
assertFalse(falseResult)
|
||||||
|
assertEquals(2, gateway.sentFrames.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createRequestDecoratesOnceAllocatesIdAndEncodesOmittingDefaults() {
|
||||||
|
var decorateCount = 0
|
||||||
|
val gateway =
|
||||||
|
FakeGateway(
|
||||||
|
scope = CoroutineScope(CompletableDeferred<Unit>()),
|
||||||
|
json = testJson,
|
||||||
|
decorateParams = { method, params ->
|
||||||
|
decorateCount++
|
||||||
|
params + ("decoratedMethod" to method)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val request = gateway.channel.createRequest("test.action", mapOf("key" to "value"))
|
||||||
|
assertEquals("1", request.id)
|
||||||
|
assertEquals("test.action", request.method)
|
||||||
|
assertEquals(1, decorateCount)
|
||||||
|
assertEquals(JsonPrimitive("value"), request.params["key"])
|
||||||
|
assertEquals(JsonPrimitive("test.action"), request.params["decoratedMethod"])
|
||||||
|
|
||||||
|
val encoded = gateway.channel.encodeRequest(request)
|
||||||
|
// Injected json with encodeDefaults = false omits default jsonrpc ("2.0")
|
||||||
|
assertFalse(encoded.contains("\"jsonrpc\""))
|
||||||
|
assertTrue(encoded.contains("\"id\":\"1\"") || encoded.contains("\"id\": \"1\""))
|
||||||
|
assertTrue(encoded.contains("\"method\":\"test.action\"") || encoded.contains("\"method\": \"test.action\""))
|
||||||
|
|
||||||
|
// An empty params map is omitted by encodeRequest under encodeDefaults = false
|
||||||
|
val plainGateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||||
|
val emptyParamsReq = plainGateway.channel.createRequest("test.empty", emptyMap())
|
||||||
|
val encodedEmpty = plainGateway.channel.encodeRequest(emptyParamsReq)
|
||||||
|
assertFalse(encodedEmpty.contains("\"params\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameOrderRawJsonResolvedBeforeEventEmittedAndDoubleOnResult() =
|
||||||
|
runTest {
|
||||||
|
val onResultCalls = mutableListOf<String>()
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway =
|
||||||
|
FakeGateway(
|
||||||
|
scope = this,
|
||||||
|
json = testJson,
|
||||||
|
onResultCallback = { onResultCalls.add(it) },
|
||||||
|
)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val deferred = gateway.channel.request("client.calc")
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
|
||||||
|
var rawResolvedAtEventTime: Boolean? = null
|
||||||
|
var onResultCallsAtEventTime = 0
|
||||||
|
val emitSink: (WsEvent) -> Unit = { event ->
|
||||||
|
events.add(event)
|
||||||
|
rawResolvedAtEventTime = deferred.isCompleted
|
||||||
|
onResultCallsAtEventTime = onResultCalls.size
|
||||||
|
}
|
||||||
|
|
||||||
|
val incomingFrame = """{"jsonrpc":"2.0","id":"$id","result":{"ans":42.0}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(incomingFrame, replayTracker, emitSink)
|
||||||
|
|
||||||
|
// onResult is invoked twice baseline: once before raw resolve, and again before parsed RpcResult resolution
|
||||||
|
assertEquals(2, onResultCalls.size)
|
||||||
|
assertEquals(listOf(id, id), onResultCalls)
|
||||||
|
assertEquals(2, onResultCallsAtEventTime)
|
||||||
|
|
||||||
|
// Raw deferred was settled BEFORE emitEvent was called
|
||||||
|
assertEquals(true, rawResolvedAtEventTime)
|
||||||
|
assertTrue(deferred.isCompleted)
|
||||||
|
|
||||||
|
// Numeric fidelity preserved on result
|
||||||
|
val rawResult = deferred.await()
|
||||||
|
assertTrue(rawResult is JsonObject)
|
||||||
|
val rawObj = rawResult as JsonObject
|
||||||
|
assertEquals(JsonPrimitive(42.0), rawObj["ans"])
|
||||||
|
|
||||||
|
// Event emitted was RpcResult
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val rpcResult = events.first() as WsEvent.RpcResult
|
||||||
|
assertEquals(id, rpcResult.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameRpcErrorInvokesCallbackAndRespectsSuppression() =
|
||||||
|
runTest {
|
||||||
|
val onErrorCalls = mutableListOf<String>()
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway =
|
||||||
|
FakeGateway(
|
||||||
|
scope = this,
|
||||||
|
json = testJson,
|
||||||
|
onErrorCallback = { onErrorCalls.add(it) },
|
||||||
|
)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// 1. Normal unsuppressed error
|
||||||
|
val normalDeferred = gateway.channel.request("call.normal", suppressErrorEvent = false)
|
||||||
|
val normalId = gateway.sentRequests[0].id
|
||||||
|
val errorFrame1 =
|
||||||
|
"""{"jsonrpc":"2.0","id":"$normalId","error":{"code":-32603,"message":"Internal error"}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(errorFrame1, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(listOf(normalId), onErrorCalls)
|
||||||
|
assertTrue(normalDeferred.isCompleted)
|
||||||
|
try {
|
||||||
|
normalDeferred.await()
|
||||||
|
fail("Expected HermesRpcException")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(-32603, e.code)
|
||||||
|
assertEquals("Internal error", e.message)
|
||||||
|
}
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
assertTrue(events.first() is WsEvent.RpcError)
|
||||||
|
assertEquals(normalId, (events.first() as WsEvent.RpcError).id)
|
||||||
|
|
||||||
|
// 2. Suppressed error resolves deferred but emits NONE
|
||||||
|
val silentDeferred = gateway.channel.request("call.silent", suppressErrorEvent = true)
|
||||||
|
val silentId = gateway.sentRequests[1].id
|
||||||
|
val errorFrame2 =
|
||||||
|
"""{"jsonrpc":"2.0","id":"$silentId","error":{"code":404,"message":"Not found"}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(errorFrame2, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(listOf(normalId, silentId), onErrorCalls)
|
||||||
|
assertTrue(silentDeferred.isCompleted)
|
||||||
|
try {
|
||||||
|
silentDeferred.await()
|
||||||
|
fail("Expected HermesRpcException on silent deferred")
|
||||||
|
} catch (e: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(404, e.code)
|
||||||
|
}
|
||||||
|
// Event list size remains 1 (no RpcError event emitted for suppressed call)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameCapabilitySuppressionHidesEventUntilCleared() =
|
||||||
|
runTest {
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val capId = "cap-42"
|
||||||
|
gateway.channel.registerCapabilityRequest(capId)
|
||||||
|
|
||||||
|
val capResponseFrame = """{"jsonrpc":"2.0","id":"$capId","result":{"supported":true}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
// Registered capability response is hidden from events
|
||||||
|
assertTrue(events.isEmpty())
|
||||||
|
|
||||||
|
// After clearing capabilities, subsequent responses with that ID are visible
|
||||||
|
gateway.channel.registerCapabilityRequest(capId)
|
||||||
|
gateway.channel.clearCapabilityRequests()
|
||||||
|
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val rpcResult = events.first() as WsEvent.RpcResult
|
||||||
|
assertEquals(capId, rpcResult.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameMalformedJsonInvokesParseFailureAndEmitsUnknown() =
|
||||||
|
runTest {
|
||||||
|
val parseFailures = mutableListOf<Exception>()
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway =
|
||||||
|
FakeGateway(
|
||||||
|
scope = this,
|
||||||
|
json = testJson,
|
||||||
|
logParseFailureCallback = { parseFailures.add(it) },
|
||||||
|
)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val malformed = "{ invalid json frame ::"
|
||||||
|
gateway.channel.handleIncomingFrame(malformed, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(1, parseFailures.size)
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val unknown = events.first() as WsEvent.Unknown
|
||||||
|
assertEquals(malformed, unknown.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameServerRequestClassifiedCorrectlyOverRpcResult() =
|
||||||
|
runTest {
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normal srq frame with id + method should emit ServerRequest, not RpcResult
|
||||||
|
val srqFrame = """{"jsonrpc":"2.0","id":"srq-101","method":"clarify","params":{"text":"Confirm?"}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(srqFrame, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val event = events.first()
|
||||||
|
assertTrue("Expected ServerRequest, got: $event", event is WsEvent.ServerRequest)
|
||||||
|
val srq = event as WsEvent.ServerRequest
|
||||||
|
assertEquals("srq-101", srq.id)
|
||||||
|
assertEquals("clarify", srq.method)
|
||||||
|
assertFalse(srq.replayed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameSequencedNotificationDeduplicatesViaReplayTracker() =
|
||||||
|
runTest {
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway = FakeGateway(this, testJson)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val seqFrame =
|
||||||
|
"""{"method":"events","params":{"type":"message.token","session_id":"s1","seq":11,"payload":{"text":"fresh"}}}"""
|
||||||
|
|
||||||
|
// First delivery (fresh seq) emits once
|
||||||
|
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val tokenEvent = events.first() as WsEvent.MessageToken
|
||||||
|
assertEquals("fresh", tokenEvent.token)
|
||||||
|
assertEquals("s1", tokenEvent.sessionId)
|
||||||
|
|
||||||
|
// Duplicate delivery (same seq 11) is dropped, emits none
|
||||||
|
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameSettlesRawResultBeforeReplayingOpenRequests() =
|
||||||
|
runTest {
|
||||||
|
val order = mutableListOf<String>()
|
||||||
|
val gateway = FakeGateway(this, testJson, onResultCallback = { order.add("result:$it") })
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = {},
|
||||||
|
)
|
||||||
|
val deferred = gateway.channel.request("session.resume")
|
||||||
|
val id = gateway.sentRequests.first().id
|
||||||
|
deferred.invokeOnCompletion { order.add("resolved") }
|
||||||
|
val frame =
|
||||||
|
"""{"id":"$id","result":{"open_requests":[{"id":"srq-open","method":"clarify","params":{"text":"replayed"}}]}}"""
|
||||||
|
|
||||||
|
gateway.channel.handleIncomingFrame(frame, replayTracker) { event ->
|
||||||
|
assertTrue("Raw deferred must settle before either event", deferred.isCompleted)
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.ServerRequest -> {
|
||||||
|
assertTrue(event.replayed)
|
||||||
|
assertEquals("clarify", event.method)
|
||||||
|
assertEquals("replayed", event.params["text"])
|
||||||
|
order.add("server:${event.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.RpcResult -> {
|
||||||
|
order.add("rpc:${event.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
fail("Unexpected event: $event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(listOf("result:$id", "resolved", "server:srq-open", "result:$id", "rpc:$id"), order)
|
||||||
|
assertTrue(deferred.await() is JsonObject)
|
||||||
|
assertFalse(gateway.channel.hasPendingCalls())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handleIncomingFrameDecoratesStoredSessionIdOnMessageComplete() =
|
||||||
|
runTest {
|
||||||
|
val events = mutableListOf<WsEvent>()
|
||||||
|
val gateway =
|
||||||
|
FakeGateway(
|
||||||
|
scope = this,
|
||||||
|
json = testJson,
|
||||||
|
resolveStoredSessionId = { sid -> "stored-$sid" },
|
||||||
|
)
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = this,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { events.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val completeFrame =
|
||||||
|
"""{"method":"events","params":{"type":"message.complete","session_id":"active-session","payload":{"text":"done","reasoning":"reason"}}}"""
|
||||||
|
gateway.channel.handleIncomingFrame(completeFrame, replayTracker) { events.add(it) }
|
||||||
|
|
||||||
|
assertEquals(1, events.size)
|
||||||
|
val completeEvent = events.first() as WsEvent.MessageComplete
|
||||||
|
assertEquals("done", completeEvent.text)
|
||||||
|
assertEquals("reason", completeEvent.reasoning)
|
||||||
|
assertEquals("active-session", completeEvent.sessionId)
|
||||||
|
assertEquals("stored-active-session", completeEvent.storedSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assert.fail
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class WsTransportTest {
|
||||||
|
private val testJson =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Fixture(
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
json: Json,
|
||||||
|
nowMs: () -> Long = {
|
||||||
|
if (scope is TestScope) {
|
||||||
|
scope.testScheduler.currentTime
|
||||||
|
} else {
|
||||||
|
System.nanoTime() / 1_000_000L
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val outboundLock = Any()
|
||||||
|
val rawMessages = mutableListOf<String>()
|
||||||
|
val replayEmittedEvents = mutableListOf<WsEvent>()
|
||||||
|
val reconnectPolicy = ReconnectPolicy()
|
||||||
|
|
||||||
|
val rpcSentFrames = mutableListOf<String>()
|
||||||
|
val rpcRemovedQueueIds = mutableListOf<String>()
|
||||||
|
var rpcIdleChecks = 0
|
||||||
|
|
||||||
|
var transportRef: WsTransport? = null
|
||||||
|
private val rpcIdCounter = AtomicInteger(1)
|
||||||
|
|
||||||
|
val connectionHealth =
|
||||||
|
ConnectionHealth(
|
||||||
|
scope = scope,
|
||||||
|
isConnected = { transportRef?.isConnected ?: false },
|
||||||
|
pingRequest = { /* ping fake unit */ },
|
||||||
|
cancelSocket = { transportRef?.cancelSocket() },
|
||||||
|
pendingReply = { transportRef?.pendingReply ?: false },
|
||||||
|
nowMs = nowMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
val replayTracker =
|
||||||
|
ReplayTracker(
|
||||||
|
scope = scope,
|
||||||
|
fetchEventsSince = { SessionEventsSinceResult() },
|
||||||
|
emitEvent = { replayEmittedEvents.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val rpcChannel =
|
||||||
|
RpcChannel(
|
||||||
|
scope = scope,
|
||||||
|
json = json,
|
||||||
|
sendRequest = { method, params, onSent ->
|
||||||
|
val id = "rpc-${rpcIdCounter.getAndIncrement()}"
|
||||||
|
val req =
|
||||||
|
rpcChannelRef?.createRequest(method, params)?.copy(id = id)
|
||||||
|
?: JsonRpcRequest(id = id, method = method)
|
||||||
|
transportRef?.send(req, onSent) ?: id
|
||||||
|
},
|
||||||
|
sendFrame = { frame ->
|
||||||
|
rpcSentFrames.add(frame)
|
||||||
|
transportRef?.sendDirectFrame(frame) ?: false
|
||||||
|
},
|
||||||
|
removeQueuedMessage = { id ->
|
||||||
|
rpcRemovedQueueIds.add(id)
|
||||||
|
transportRef?.removeQueuedMessage(id)
|
||||||
|
},
|
||||||
|
onIdleCheck = {
|
||||||
|
rpcIdleChecks++
|
||||||
|
transportRef?.disconnectIfIdleInBackground()
|
||||||
|
},
|
||||||
|
outboundLock = outboundLock,
|
||||||
|
onResult = { id -> transportRef?.onRpcResult(id) },
|
||||||
|
onError = { id -> transportRef?.onRpcError(id) },
|
||||||
|
).also { rpcChannelRef = it }
|
||||||
|
|
||||||
|
val transport =
|
||||||
|
WsTransport(
|
||||||
|
scope = scope,
|
||||||
|
outboundLock = outboundLock,
|
||||||
|
reconnectPolicy = reconnectPolicy,
|
||||||
|
connectionHealth = connectionHealth,
|
||||||
|
replayTracker = replayTracker,
|
||||||
|
rpcChannel = rpcChannel,
|
||||||
|
onRawMessage = { rawMessages.add(it) },
|
||||||
|
).also { transportRef = it }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var rpcChannelRef: RpcChannel? = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||||
|
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
|
|
||||||
|
mockkObject(NetworkMonitor)
|
||||||
|
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendAcceptCallbackRunsUnderOutboundLockAfterSocketAccept() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns true
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
var callbackRan = false
|
||||||
|
var callbackThreadHoldsLock = false
|
||||||
|
val request = JsonRpcRequest(id = "req-1", method = "session.list")
|
||||||
|
|
||||||
|
val id =
|
||||||
|
fixture.transport.send(
|
||||||
|
request = request,
|
||||||
|
onSent = {
|
||||||
|
callbackRan = true
|
||||||
|
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("req-1", id)
|
||||||
|
assertTrue(callbackRan)
|
||||||
|
assertTrue(callbackThreadHoldsLock)
|
||||||
|
verify(exactly = 1) { socket.send(any<String>()) }
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedRetryableSendEnqueuesAndCancelDoesNotAutoRedialWhenAutoReconnectDisabled() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns false
|
||||||
|
every { socket.queueSize() } returns 0L
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
var onSentCalled = false
|
||||||
|
var callbackThreadHoldsLock = false
|
||||||
|
val request = JsonRpcRequest(id = "req-queued", method = "prompt.submit")
|
||||||
|
|
||||||
|
fixture.transport.send(
|
||||||
|
request = request,
|
||||||
|
onSent = {
|
||||||
|
onSentCalled = true
|
||||||
|
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
|
||||||
|
},
|
||||||
|
queueIfDisconnected = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
// When socket rejects, request is pushed back into retryable queue
|
||||||
|
val queue = fixture.transport.messageQueueForTest
|
||||||
|
assertEquals(1, queue.size)
|
||||||
|
assertTrue(queue.peek()?.contains("req-queued") == true)
|
||||||
|
assertTrue(onSentCalled)
|
||||||
|
assertTrue(callbackThreadHoldsLock)
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||||
|
assertNull(fixture.transport.reconnectJobForTest)
|
||||||
|
verify(exactly = 1) { socket.cancel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleOnMessageIgnoredWhileCurrentGenerationFrameUpdatesHealthAndRawCallback() =
|
||||||
|
runTest {
|
||||||
|
val fixture =
|
||||||
|
Fixture(
|
||||||
|
scope = this,
|
||||||
|
json = testJson,
|
||||||
|
nowMs = { testScheduler.currentTime },
|
||||||
|
)
|
||||||
|
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(2)
|
||||||
|
fixture.transport.webSocketForTest = currentSocket
|
||||||
|
|
||||||
|
val initialTimestamp = fixture.connectionHealth.lastPongTimestamp
|
||||||
|
assertEquals(0L, initialTimestamp)
|
||||||
|
|
||||||
|
val staleListener = fixture.transport.createListenerForTest(1)
|
||||||
|
val currentListener = fixture.transport.createListenerForTest(2)
|
||||||
|
|
||||||
|
advanceTimeBy(1_000L)
|
||||||
|
// Message from stale listener/generation should be discarded
|
||||||
|
staleListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old\"}")
|
||||||
|
staleListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old-mismatched-gen\"}")
|
||||||
|
assertTrue(fixture.rawMessages.isEmpty())
|
||||||
|
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
|
||||||
|
|
||||||
|
advanceTimeBy(1_000L)
|
||||||
|
// Message from current generation but mismatched socket should be discarded
|
||||||
|
currentListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"wrong-socket\"}")
|
||||||
|
assertTrue(fixture.rawMessages.isEmpty())
|
||||||
|
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
|
||||||
|
|
||||||
|
advanceTimeBy(1_000L)
|
||||||
|
val expectedTimestamp = testScheduler.currentTime
|
||||||
|
// Matching generation and matching active socket passes through and updates health
|
||||||
|
currentListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}")
|
||||||
|
assertEquals(listOf("{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}"), fixture.rawMessages)
|
||||||
|
assertEquals(expectedTimestamp, fixture.connectionHealth.lastPongTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleOnOpenClosesSupersededWithoutChangingOwnershipOrFlushingQueue() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||||
|
val response = mockk<Response>(relaxed = true)
|
||||||
|
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(2)
|
||||||
|
fixture.transport.webSocketForTest = currentSocket
|
||||||
|
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"queued-1\"}")
|
||||||
|
|
||||||
|
val staleListener = fixture.transport.createListenerForTest(1)
|
||||||
|
staleListener.onOpen(staleSocket, response)
|
||||||
|
|
||||||
|
// Stale onOpen must close the stale socket with 1000 ("Superseded")
|
||||||
|
verify(exactly = 1) { staleSocket.close(1000, "Superseded") }
|
||||||
|
// Must not change active socket or flush the queue on stale socket
|
||||||
|
verify(exactly = 0) { staleSocket.send(any<String>()) }
|
||||||
|
assertEquals(currentSocket, fixture.transport.webSocketForTest)
|
||||||
|
assertEquals(1, fixture.transport.messageQueueForTest.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun authClosingCodeSetsTerminalAuthExpiredAndDoesNotAutoRedial() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns true
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
// Register a pending RPC call to verify it is NOT newly rejected
|
||||||
|
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
val listener = fixture.transport.createListenerForTest(1)
|
||||||
|
listener.onClosing(socket, 4401, "unauthorized")
|
||||||
|
|
||||||
|
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
assertNull(fixture.transport.reconnectJobForTest)
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
listener.onClosed(socket, 4401, "unauthorized")
|
||||||
|
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
|
||||||
|
assertNull(fixture.transport.reconnectJobForTest)
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
pendingDeferred.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disconnectPreservesPendingCallsWhileClearFlagControlsQueueAndLeaseCleanup() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns true
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
// Register actual pending RPC call over socket accepting send
|
||||||
|
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
// Populate retryable message queue
|
||||||
|
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"}")
|
||||||
|
|
||||||
|
// Ordinary disconnect preserves queued messages and does NOT reject pending calls
|
||||||
|
fixture.transport.disconnect(clearPendingMessages = false)
|
||||||
|
assertEquals(1, fixture.transport.messageQueueForTest.size)
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
// Disconnect with clearPendingMessages = true clears queue and resets leases, still not rejecting pending calls
|
||||||
|
fixture.transport.acquireBackgroundConnectionLease()
|
||||||
|
assertTrue(fixture.transport.hasBackgroundConnectionLease())
|
||||||
|
|
||||||
|
fixture.transport.disconnect(clearPendingMessages = true)
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
assertFalse(fixture.transport.hasBackgroundConnectionLease())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
pendingDeferred.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconnectForNetworkChangeRejectsPendingCallsAndExecutesReplacementCallbackOnce() =
|
||||||
|
runTest {
|
||||||
|
every { AuthManager.isAutoReconnect() } returns true
|
||||||
|
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send(any<String>()) } returns true
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.intentionalCloseForTest.set(false)
|
||||||
|
fixture.transport.connectionGenerationForTest.set(1)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
// Register non-empty pending RPC while socket accepts send
|
||||||
|
val pendingDeferred = fixture.rpcChannel.request("custom.network_call", emptyMap())
|
||||||
|
assertFalse(pendingDeferred.isCompleted)
|
||||||
|
|
||||||
|
var replacementOpens = 0
|
||||||
|
fixture.transport.reconnectForNetworkChange(
|
||||||
|
networkAvailable = true,
|
||||||
|
openReplacement = { replacementOpens++ },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outgoing socket canceled once
|
||||||
|
verify(exactly = 1) { socket.cancel() }
|
||||||
|
assertEquals(1, replacementOpens)
|
||||||
|
assertEquals(2, fixture.transport.connectionGenerationForTest.get())
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
|
||||||
|
assertNull(fixture.transport.webSocketForTest)
|
||||||
|
|
||||||
|
// Network replacement uses the default connection-lost exception, not a timeout.
|
||||||
|
assertTrue(pendingDeferred.isCompleted)
|
||||||
|
try {
|
||||||
|
pendingDeferred.await()
|
||||||
|
fail("Expected HermesRpcException")
|
||||||
|
} catch (exception: HermesWsClient.HermesRpcException) {
|
||||||
|
assertEquals(0, exception.code)
|
||||||
|
assertEquals("Connection lost — request cancelled", exception.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun combinedLeasesSurviveIdleBackgroundUntilBothReleased() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
|
||||||
|
|
||||||
|
fixture.transport.acquireExternalActivityConnectionLease()
|
||||||
|
fixture.transport.acquireBackgroundConnectionLease()
|
||||||
|
assertTrue(fixture.transport.hasBackgroundConnectionLease())
|
||||||
|
|
||||||
|
// Backgrounding app with both leases active keeps connection alive
|
||||||
|
fixture.transport.setAppForeground(false)
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
// Releasing external lease keeps connection alive due to background lease
|
||||||
|
fixture.transport.releaseExternalActivityConnectionLease()
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
// Releasing background lease releases all leases and disconnects idle background socket
|
||||||
|
fixture.transport.releaseBackgroundConnectionLease()
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observeEventMarksPendingReplyStartAndCompleteRespectingIdlePolicy() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
|
||||||
|
|
||||||
|
// When a message start event arrives BEFORE setAppForeground(false)
|
||||||
|
val startEvent = WsEvent.MessageStart(sessionId = "s1")
|
||||||
|
fixture.transport.observeEvent(startEvent)
|
||||||
|
assertTrue(fixture.transport.pendingReply)
|
||||||
|
|
||||||
|
// Setting app foreground to false while pending reply is true stays connected
|
||||||
|
fixture.transport.setAppForeground(false)
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
// Idle disconnect check while pending reply is true keeps socket connected
|
||||||
|
fixture.transport.disconnectIfIdleInBackground()
|
||||||
|
assertTrue(fixture.transport.isConnected)
|
||||||
|
|
||||||
|
// Message complete event clears pending reply and immediately idle disconnects
|
||||||
|
val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "s1")
|
||||||
|
fixture.transport.observeEvent(completeEvent)
|
||||||
|
assertFalse(fixture.transport.pendingReply)
|
||||||
|
assertFalse(fixture.transport.isConnected)
|
||||||
|
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendDirectFrameBypassesQueueAndReturnsFalseWhenDisconnected() =
|
||||||
|
runTest {
|
||||||
|
val fixture = Fixture(this, testJson)
|
||||||
|
val socket = mockk<WebSocket>(relaxed = true)
|
||||||
|
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}") } returns true
|
||||||
|
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") } returns false
|
||||||
|
|
||||||
|
fixture.transport.connectedForTest.set(true)
|
||||||
|
fixture.transport.webSocketForTest = socket
|
||||||
|
|
||||||
|
// 1. Connected with socket accepting send -> returns true, no queue
|
||||||
|
val connectedResult =
|
||||||
|
fixture.transport.sendDirectFrame(
|
||||||
|
"{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}",
|
||||||
|
)
|
||||||
|
assertTrue(connectedResult)
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
|
||||||
|
// 2. Connected with socket rejecting send -> returns false, no queue
|
||||||
|
val sendFailsResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}")
|
||||||
|
assertFalse(sendFailsResult)
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
|
||||||
|
// 3. Disconnected -> returns false, no queue
|
||||||
|
fixture.transport.connectedForTest.set(false)
|
||||||
|
fixture.transport.webSocketForTest = null
|
||||||
|
|
||||||
|
val disconnectedResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"direct\"}")
|
||||||
|
assertFalse(disconnectedResult)
|
||||||
|
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user