16 files changed
+5285
-1913
No files matched your search
@@ -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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class HermesWsClientLoggingSecurityTest {
|
||||
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
@@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest {
|
||||
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
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.connectedForTest.set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
@@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun createWsListener(generation: Int? = null): WebSocketListener {
|
||||
val targetGen =
|
||||
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 createWsListener(generation: Int? = null): WebSocketListener =
|
||||
HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get())
|
||||
|
||||
private fun attachMockSocket(): WebSocket {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest {
|
||||
val listener = createWsListener()
|
||||
|
||||
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val sensitiveAuthUrl =
|
||||
@@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest {
|
||||
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
||||
|
||||
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val errorJson =
|
||||
@@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest {
|
||||
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
|
||||
attachMockSocket()
|
||||
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val entry =
|
||||
pendingCalls.entries.firstOrNull {
|
||||
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)
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call must be registered in pendingCalls map", reqId)
|
||||
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!)
|
||||
assertNotNull("Timeout job must be initialized", timeoutJob)
|
||||
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)
|
||||
assertFalse(
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -91,27 +91,15 @@ class HermesWsClientTest {
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
// Reset state
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.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.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.connectedForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
|
||||
HermesWsClient.messageQueueForTest.clear()
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||
acceptQueuedMessagesField.isAccessible = true
|
||||
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.acceptQueuedMessagesForTest.set(true)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -350,10 +338,8 @@ class HermesWsClientTest {
|
||||
val socket = mockk<WebSocket>()
|
||||
every { socket.send(any<String>()) } returns true
|
||||
every { socket.close(any(), any()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
||||
socketField.set(HermesWsClient, socket)
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
val sent =
|
||||
HermesWsClient.respondToServerRequest(
|
||||
@@ -384,10 +370,8 @@ class HermesWsClientTest {
|
||||
val socket = mockk<WebSocket>()
|
||||
every { socket.send(any<String>()) } returns true
|
||||
every { socket.close(any(), any()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
||||
socketField.set(HermesWsClient, socket)
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
|
||||
verify {
|
||||
@@ -416,29 +400,14 @@ class HermesWsClientTest {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.CONNECTED
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
assertEquals(1, queue.size)
|
||||
@@ -446,8 +415,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
|
||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true }
|
||||
val outboundLock = lockField.get(HermesWsClient)
|
||||
val outboundLock = HermesWsClient.outboundLockForTest
|
||||
var callbackHeldLock = false
|
||||
|
||||
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
|
||||
@@ -458,25 +426,13 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
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
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
val oldGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val staleListener = HermesWsClient.createListenerForTest(oldGeneration)
|
||||
var replacementOpens = 0
|
||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||
|
||||
@@ -486,10 +442,10 @@ class HermesWsClientTest {
|
||||
|
||||
verify(exactly = 1) { socket.cancel() }
|
||||
assertEquals(1, replacementOpens)
|
||||
assertEquals(oldGeneration + 1, generation.get())
|
||||
assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get())
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
||||
assertEquals(null, socketField.get(HermesWsClient))
|
||||
assertEquals(null, HermesWsClient.webSocketForTest)
|
||||
assertTrue(deferred.isCompleted)
|
||||
}
|
||||
|
||||
@@ -497,18 +453,10 @@ class HermesWsClientTest {
|
||||
fun testNetworkLossPreservesAuthExpiredStatus() =
|
||||
runBlocking {
|
||||
every { AuthManager.isAutoReconnect() } returns 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")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange(false)
|
||||
HermesWsClient.reconnectForNetworkChange(true)
|
||||
@@ -520,22 +468,13 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { oldSocket.send(any<String>()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, oldSocket)
|
||||
HermesWsClient.webSocketForTest = oldSocket
|
||||
HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true))
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange(false)
|
||||
@@ -545,13 +484,7 @@ class HermesWsClientTest {
|
||||
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
|
||||
val replacementSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { replacementSocket.send(any<String>()) } returns true
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
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
|
||||
val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
|
||||
|
||||
assertEquals(1, replacementOpens)
|
||||
@@ -563,18 +496,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testNetworkChangePreservesAuthExpiredSocket() {
|
||||
every { AuthManager.isAutoReconnect() } returns 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")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange()
|
||||
|
||||
@@ -588,20 +513,13 @@ class HermesWsClientTest {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.currentBackoffForTest = 1_000L
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -610,35 +528,21 @@ class HermesWsClientTest {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.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 lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
|
||||
lockField.isAccessible = true
|
||||
val lock = lockField.get(HermesWsClient)
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
val lock = HermesWsClient.outboundLockForTest
|
||||
|
||||
synchronized(lock) {
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectJobField.set(HermesWsClient, replacementJob)
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
HermesWsClient.reconnectJobForTest = replacementJob
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
|
||||
assertSame(replacementJob, HermesWsClient.reconnectJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -646,23 +550,15 @@ class HermesWsClientTest {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
HermesWsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
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)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@@ -687,24 +580,14 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -717,35 +600,18 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.currentBackoffForTest = 1_000L
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -754,38 +620,22 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
listener.onOpen(pressuredSocket, mockk(relaxed = true))
|
||||
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(1, queue.size)
|
||||
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -794,29 +644,15 @@ class HermesWsClientTest {
|
||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { currentSocket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, currentSocket)
|
||||
HermesWsClient.webSocketForTest = currentSocket
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(2)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(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>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val staleListener = constructor.newInstance(1) as WebSocketListener
|
||||
val currentListener = constructor.newInstance(2) as WebSocketListener
|
||||
val staleListener = HermesWsClient.createListenerForTest(1)
|
||||
val currentListener = HermesWsClient.createListenerForTest(2)
|
||||
val response = mockk<okhttp3.Response>(relaxed = true)
|
||||
|
||||
staleListener.onOpen(staleSocket, response)
|
||||
@@ -835,40 +671,24 @@ class HermesWsClientTest {
|
||||
false
|
||||
}
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.disconnect()
|
||||
@@ -886,16 +706,11 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
@@ -910,26 +725,12 @@ class HermesWsClientTest {
|
||||
every { socket.send(any<String>()) } returns false
|
||||
every { socket.queueSize() } returns 0L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onClosing(socket, 4401, "expired")
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
@@ -941,21 +742,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testSocketFailureDoesNotOverwriteAuthExpired() {
|
||||
val socket = mockk<WebSocket>(relaxed = 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 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
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onFailure(socket, IOException("network changed"), null)
|
||||
|
||||
@@ -965,18 +755,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testExplicitConnectRetriesAfterAuthExpired() =
|
||||
runBlocking {
|
||||
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 connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, oldSocket)
|
||||
HermesWsClient.webSocketForTest = oldSocket
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
|
||||
HermesWsClient.connect()
|
||||
@@ -1008,17 +790,16 @@ class HermesWsClientTest {
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
||||
val disconnectedGeneration = generation.get()
|
||||
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val replacementConnect = thread { HermesWsClient.connect() }
|
||||
val generationDeadline = System.currentTimeMillis() + 5_000
|
||||
while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) {
|
||||
while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration &&
|
||||
System.currentTimeMillis() < generationDeadline
|
||||
) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue(generation.get() > disconnectedGeneration)
|
||||
val currentGeneration = generation.get()
|
||||
assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration)
|
||||
val currentGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
|
||||
releaseRefresh.countDown()
|
||||
staleConnect.join(5_000)
|
||||
@@ -1029,7 +810,7 @@ class HermesWsClientTest {
|
||||
|
||||
assertFalse(staleConnect.isAlive)
|
||||
assertFalse(replacementConnect.isAlive)
|
||||
assertEquals(currentGeneration, generation.get())
|
||||
assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get())
|
||||
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
|
||||
verify(exactly = 0) { AuthManager.setToken("stale-token") }
|
||||
}
|
||||
@@ -1441,12 +1222,10 @@ class HermesWsClientTest {
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
// After connect, backoff should be back to initial
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
assertEquals(
|
||||
"Backoff should reset to initial after successful connect",
|
||||
1000L,
|
||||
backoffField.getLong(HermesWsClient),
|
||||
HermesWsClient.currentBackoffForTest,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1999,26 +1778,11 @@ class HermesWsClientTest {
|
||||
fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() {
|
||||
// Issue #1163: exercise the real listener, including the early duplicate return.
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalClose.isAccessible = true
|
||||
(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
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||
// Directly install the reflection-built listener so onMessage runs even
|
||||
// though this test never performed a real OkHttp connect.
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
mockkObject(EventParser)
|
||||
|
||||
val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]")
|
||||
@@ -2039,18 +1803,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testTerminalCloseCode4403SetsAuthExpired() {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
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
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onClosing(socket, 4403, "forbidden origin")
|
||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
||||
@@ -2182,9 +1938,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
val failureLatch = CountDownLatch(1)
|
||||
HermesWsClient.probeLivenessOnTransportChange(
|
||||
@@ -2199,9 +1953,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
|
||||
var failureInvoked = false
|
||||
HermesWsClient.probeLivenessOnTransportChange(
|
||||
|
||||
@@ -43,7 +43,6 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
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
|
||||
fun callPutsExactFrameOnTheWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
@@ -516,19 +508,25 @@ class HermesWsClientTypedCallTest {
|
||||
|
||||
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val pending = getPendingCalls()
|
||||
assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size)
|
||||
assertEquals(
|
||||
"Should have exactly 1 pending call while awaiting",
|
||||
1,
|
||||
HermesWsClient.pendingCallIdsForTest().size,
|
||||
)
|
||||
|
||||
callJob.cancel()
|
||||
|
||||
// Wait briefly for cancellation cleanup to execute
|
||||
withTimeout(2000) {
|
||||
while (pending.isNotEmpty()) {
|
||||
while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) {
|
||||
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