fix: keep transient ticket failures reconnecting (#740)
* fix: keep transient ticket failures reconnecting * fix: recover rejected websocket sends * fix: serialize websocket reconnect scheduling
This commit is contained in:
1 parent
13895585b0
commit
e39544ebd5
4 files changed
+748
-113
No files matched your search
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import io.mockk.every
|
||||
@@ -11,6 +12,7 @@ import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -24,9 +26,11 @@ import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -72,12 +76,20 @@ class HermesWsClientTest {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
|
||||
|
||||
HermesWsClient.disconnect() // Ensure it starts clean
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||
acceptQueuedMessagesField.isAccessible = true
|
||||
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
||||
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
||||
Thread.sleep(500)
|
||||
@@ -136,6 +148,395 @@ class HermesWsClientTest {
|
||||
assertTrue(msg.contains(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFailedSocketSendQueuesMessageForReconnect() {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.CONNECTED
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
assertEquals(1, queue.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReconnectSchedulingIsIdempotentWhilePending() {
|
||||
mockkObject(NetworkMonitor)
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCompletedReconnectDoesNotClearReplacementJob() {
|
||||
mockkObject(NetworkMonitor)
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 0L)
|
||||
|
||||
val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob")
|
||||
reconnectJobField.isAccessible = true
|
||||
val replacementJob = mockk<Job>(relaxed = true)
|
||||
|
||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
|
||||
lockField.isAccessible = true
|
||||
val lock = lockField.get(HermesWsClient)
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
|
||||
synchronized(lock) {
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectJobField.set(HermesWsClient, replacementJob)
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOversizedRejectedMessageIsNotQueued() {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
HermesWsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertTrue(queue.isEmpty())
|
||||
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectedOversizedMessageIsNotQueued() {
|
||||
HermesWsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueuePressureDoesNotCancelAcceptedFrames() {
|
||||
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFailureDuringOutboundDrainSchedulesReconnect() {
|
||||
mockkObject(NetworkMonitor)
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReplayPressureSchedulesRecoveryWithoutDroppingHead() {
|
||||
val pressuredSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
listener.onOpen(pressuredSocket, mockk(relaxed = true))
|
||||
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(1, queue.size)
|
||||
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testStaleOnOpenDoesNotReplayQueue() {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { currentSocket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, currentSocket)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val staleListener = constructor.newInstance(1) as WebSocketListener
|
||||
val currentListener = constructor.newInstance(2) as WebSocketListener
|
||||
val response = mockk<okhttp3.Response>(relaxed = true)
|
||||
|
||||
staleListener.onOpen(staleSocket, response)
|
||||
currentListener.onOpen(currentSocket, response)
|
||||
|
||||
io.mockk.verify(exactly = 0) { staleSocket.send(any<String>()) }
|
||||
io.mockk.verify(exactly = 1) { currentSocket.send(any<String>()) }
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRejectedSendAfterDisconnectIsNotQueued() {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } answers {
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
false
|
||||
}
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertTrue(queue.isEmpty())
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.disconnect()
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "during reconnect"))
|
||||
assertEquals(2, queue.size)
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
assertTrue(queue.isEmpty())
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "after logout"))
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
|
||||
assertTrue(deferred.isCompleted)
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthClosingCodeSurvivesRejectedSendRecovery() {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns false
|
||||
every { socket.queueSize() } returns 0L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
|
||||
listener.onClosing(socket, 4401, "expired")
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
||||
io.mockk.verify(exactly = 0) { socket.cancel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReceiveMessage() {
|
||||
var serverWebSocket: WebSocket? = null
|
||||
@@ -211,7 +612,7 @@ class HermesWsClientTest {
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
assertTrue(serverLatch.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
|
||||
@@ -378,7 +779,7 @@ class HermesWsClientTest {
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
// Disconnect — this sets intentionalClose = true and cancels reconnect
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
@@ -482,7 +883,7 @@ class HermesWsClientTest {
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
// Disconnect (sets intentionalClose) — after this, reconnect should be prevented
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
}
|
||||
@@ -557,4 +958,54 @@ class HermesWsClientTest {
|
||||
|
||||
ticketServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGatedMode_permanentTicketFailureStopsRetrying() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState(wsAuthParam = "ticket")
|
||||
}
|
||||
|
||||
val ticketServer = MockWebServer()
|
||||
ticketServer.start()
|
||||
ticketServer.enqueue(MockResponse().setResponseCode(400))
|
||||
every { AuthManager.endpointForBuild() } returns
|
||||
ServerEndpoint.parse(
|
||||
ticketServer.url("/").toString(),
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
)
|
||||
|
||||
HermesWsClient.connect()
|
||||
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
ticketServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGatedMode_transientTicketFailureKeepsReconnectFlow() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState(wsAuthParam = "ticket")
|
||||
}
|
||||
|
||||
val unavailableTicketServer = MockWebServer()
|
||||
unavailableTicketServer.start()
|
||||
val unavailableEndpoint = unavailableTicketServer.url("/").toString()
|
||||
unavailableTicketServer.shutdown()
|
||||
every { AuthManager.endpointForBuild() } returns
|
||||
ServerEndpoint.parse(
|
||||
unavailableEndpoint,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
)
|
||||
|
||||
HermesWsClient.connect()
|
||||
|
||||
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user