fix(ws): reconnect after network handover (#808)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 18:09:22 +03:00
1 parent 83ec590ed8
commit d1dac57ccb
4 files changed
+536 -73

No files matched your search

@@ -0,0 +1,97 @@
package com.m57.hermescontrol.data.remote
import android.net.ConnectivityManager
import android.net.Network
import app.cash.turbine.test
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class NetworkMonitorTest {
@Before
fun setUp() {
val defaultNetworkField = NetworkMonitor::class.java.getDeclaredField("defaultNetwork")
defaultNetworkField.isAccessible = true
defaultNetworkField.set(NetworkMonitor, null)
val connectedField = NetworkMonitor::class.java.getDeclaredField("_isConnected")
connectedField.isAccessible = true
@Suppress("UNCHECKED_CAST")
(connectedField.get(NetworkMonitor) as MutableStateFlow<Boolean>).value = true
}
@Test
fun `losing the old default network keeps connectivity when replacement is active`() {
val connectivityManager = mockk<ConnectivityManager>()
val oldNetwork = mockk<Network>()
val replacement = mockk<Network>()
every { connectivityManager.activeNetwork } returns replacement
val callback = NetworkMonitor.createCallback(connectivityManager)
callback.onAvailable(oldNetwork)
callback.onLost(oldNetwork)
assertTrue(NetworkMonitor.isConnected.value)
}
@Test
fun `old network loss after replacement availability is ignored`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val oldNetwork = mockk<Network>()
val replacement = mockk<Network>()
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.networkChanges.test {
callback.onAvailable(oldNetwork)
assertTrue(awaitItem())
callback.onAvailable(replacement)
assertTrue(awaitItem())
callback.onLost(oldNetwork)
assertTrue(NetworkMonitor.isConnected.value)
expectNoEvents()
}
}
@Test
fun `callback from previous registration is ignored after restart`() {
val connectivityManager = mockk<ConnectivityManager>()
val oldNetwork = mockk<Network>()
val replacement = mockk<Network>()
every { connectivityManager.activeNetwork } returns null
val staleCallback = NetworkMonitor.createCallback(connectivityManager)
staleCallback.onAvailable(oldNetwork)
val currentCallback = NetworkMonitor.createCallback(connectivityManager)
currentCallback.onAvailable(replacement)
staleCallback.onLost(oldNetwork)
assertTrue(NetworkMonitor.isConnected.value)
}
@Test
fun `break before make emits loss and replacement identity transitions in order`() =
runTest {
val connectivityManager = mockk<ConnectivityManager>()
val oldNetwork = mockk<Network>()
val replacement = mockk<Network>()
every { connectivityManager.activeNetwork } returns null
val callback = NetworkMonitor.createCallback(connectivityManager)
NetworkMonitor.networkChanges.test {
callback.onAvailable(oldNetwork)
callback.onLost(oldNetwork)
callback.onAvailable(replacement)
assertEquals(true, awaitItem())
assertEquals(false, awaitItem())
assertEquals(true, awaitItem())
expectNoEvents()
}
}
}
@@ -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.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
@@ -12,6 +13,7 @@ import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@@ -33,6 +35,8 @@ import org.junit.Test
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
class HermesWsClientTest {
private lateinit var mockWebServer: MockWebServer
@@ -181,6 +185,144 @@ class HermesWsClientTest {
assertEquals(1, queue.size)
}
@Test
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true }
val outboundLock = lockField.get(HermesWsClient)
var callbackHeldLock = false
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
assertTrue(callbackHeldLock)
}
@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)
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
var replacementOpens = 0
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
HermesWsClient.reconnectForNetworkChange { replacementOpens++ }
staleListener.onClosing(socket, 4401, "expired")
staleListener.onFailure(socket, IOException("old network lost"), null)
verify(exactly = 1) { socket.cancel() }
assertEquals(1, replacementOpens)
assertEquals(oldGeneration + 1, generation.get())
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
assertEquals(null, socketField.get(HermesWsClient))
assertTrue(deferred.isCompleted)
}
@Test
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
val socket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.reconnectForNetworkChange(false)
HermesWsClient.reconnectForNetworkChange(true)
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
verify(exactly = 0) { socket.cancel() }
}
@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)
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.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>
assertTrue(queue.isEmpty())
HermesWsClient.reconnectForNetworkChange(false)
assertTrue(queue.isEmpty())
var replacementOpens = 0
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
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
assertEquals(1, replacementOpens)
assertTrue(queue.isEmpty())
verify(exactly = 1) { oldSocket.cancel() }
verify(exactly = 0) { replacementSocket.send(any<String>()) }
}
@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
val socket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.reconnectForNetworkChange()
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
verify(exactly = 0) { socket.cancel() }
}
@Test
fun testReconnectSchedulingIsIdempotentWhilePending() {
mockkObject(NetworkMonitor)
@@ -537,6 +679,103 @@ class HermesWsClientTest {
io.mockk.verify(exactly = 0) { socket.cancel() }
}
@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
listener.onFailure(socket, IOException("network changed"), null)
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
}
@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)
val oldSocket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, oldSocket)
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
withTimeout(5_000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
verify(exactly = 1) { oldSocket.cancel() }
}
@Test
fun testDisconnectThenConnectSupersedesBlockedSocketOpen() =
runBlocking {
mockkObject(DashboardSessionTokenRefresher)
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString()
val refreshStarted = CountDownLatch(1)
val releaseRefresh = CountDownLatch(1)
val refreshCalls = AtomicInteger(0)
every { DashboardSessionTokenRefresher.fetch(any(), any()) } answers {
if (refreshCalls.getAndIncrement() == 0) {
refreshStarted.countDown()
releaseRefresh.await(5, TimeUnit.SECONDS)
"stale-token"
} else {
null
}
}
val staleConnect = thread { HermesWsClient.connect() }
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
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 replacementConnect = thread { HermesWsClient.connect() }
val generationDeadline = System.currentTimeMillis() + 5_000
while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) {
Thread.sleep(10)
}
assertTrue(generation.get() > disconnectedGeneration)
val currentGeneration = generation.get()
releaseRefresh.countDown()
staleConnect.join(5_000)
replacementConnect.join(5_000)
withTimeout(5_000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
assertFalse(staleConnect.isAlive)
assertFalse(replacementConnect.isAlive)
assertEquals(currentGeneration, generation.get())
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
verify(exactly = 0) { AuthManager.setToken("stale-token") }
}
@Test
fun testReceiveMessage() {
var serverWebSocket: WebSocket? = null
@@ -780,9 +1019,13 @@ class HermesWsClientTest {
// Disconnect — this sets intentionalClose = true and cancels reconnect
HermesWsClient.disconnect(clearPendingMessages = true)
var replacementOpens = 0
HermesWsClient.reconnectForNetworkChange(false)
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
assertEquals(0, replacementOpens)
}
@Test