fix(core): application-scoped runtimes, strict multi-host singletons, and replay=0 for transient events

This commit is contained in:
Ochenstarik 2026-08-24 09:15:37 +07:00
parent 7e2512e390
commit 57677b7770
13 changed files with 244 additions and 31 deletions

View file

@ -106,6 +106,7 @@ dependencies {
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("app.cash.turbine:turbine:1.2.0")

View file

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".HermesApplication"
android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"

View file

@ -0,0 +1,59 @@
package app.hermes.mobile
import android.content.Context
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.repository.UnifiedSessionRepository
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.security.EncryptedTokenVault
import app.hermes.mobile.core.storage.HermesDatabase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
interface AppContainer {
val db: HermesDatabase
val tokenVault: EncryptedTokenVault
val restClient: HermesRestClient
val pkceAuthManager: PkceLoopbackAuthManager
val connectionManager: HermesConnectionManager
val unifiedSessionRepo: UnifiedSessionRepository
val applicationScope: CoroutineScope
}
class HermesAppContainer(private val context: Context) : AppContainer {
override val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override val db: HermesDatabase by lazy {
HermesDatabase.getInstance(context)
}
override val tokenVault: EncryptedTokenVault by lazy {
EncryptedTokenVault(context)
}
override val restClient: HermesRestClient by lazy {
HermesRestClient()
}
override val pkceAuthManager: PkceLoopbackAuthManager by lazy {
PkceLoopbackAuthManager(restClient, tokenVault)
}
override val connectionManager: HermesConnectionManager by lazy {
HermesConnectionManager(
hostDao = db.hostDao(),
tokenVault = tokenVault,
restClient = restClient,
scope = applicationScope
)
}
override val unifiedSessionRepo: UnifiedSessionRepository by lazy {
UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = db.unifiedSessionDao(),
scope = applicationScope
)
}
}

View file

@ -0,0 +1,18 @@
package app.hermes.mobile
import android.app.Application
import kotlinx.coroutines.cancel
class HermesApplication : Application() {
lateinit var container: AppContainer
override fun onCreate() {
super.onCreate()
container = HermesAppContainer(this)
}
override fun onTerminate() {
super.onTerminate()
container.applicationScope.cancel()
}
}

View file

@ -43,28 +43,19 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val db = HermesDatabase.getInstance(applicationContext)
val container = (applicationContext as HermesApplication).container
val db = container.db
val hostDao = db.hostDao()
val sessionDao = db.unifiedSessionDao()
val tokenVault = EncryptedTokenVault(applicationContext)
val restClient = HermesRestClient()
val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
// Migrate legacy connections from DataStore if present
lifecycleScope.launch {
MigrationHelper.migrateLegacyConnections(applicationContext, hostDao)
}
val connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
restClient = restClient
)
val unifiedSessionRepo = UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = sessionDao
)
val tokenVault = container.tokenVault
val pkceAuthManager = container.pkceAuthManager
val connectionManager = container.connectionManager
val unifiedSessionRepo = container.unifiedSessionRepo
setContent {
HermesAndroidTheme {

View file

@ -77,7 +77,7 @@ class JsonRpcGatewayClient(
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _events = MutableSharedFlow<GatewayEvent>(replay = 1, extraBufferCapacity = 64)
private val _events = MutableSharedFlow<GatewayEvent>(extraBufferCapacity = 64)
val events: SharedFlow<GatewayEvent> = _events.asSharedFlow()
private fun nextId(): String = "a${reqCounter.incrementAndGet()}"

View file

@ -26,13 +26,14 @@ class HermesConnectionManager(
val tokenVault: TokenVault,
val restClient: HermesRestClient = HermesRestClient(),
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
val runtimeFactory: (HermesHost) -> HermesHostRuntime = { host ->
val runtimeFactory: (CoroutineScope, HermesHost) -> HermesHostRuntime = { parentScope, host ->
val childScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + Dispatchers.Default)
HermesHostRuntime(
initialHost = host,
restClient = restClient,
gatewayClient = JsonRpcGatewayClient(scope = scope),
gatewayClient = JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault,
scope = scope
scope = childScope
)
}
) {
@ -44,7 +45,7 @@ class HermesConnectionManager(
private val _activeHostId = MutableStateFlow<HermesHostId?>(null)
val activeHostId: StateFlow<HermesHostId?> = _activeHostId.asStateFlow()
private val _allEvents = MutableSharedFlow<HostGatewayEvent>(replay = 1, extraBufferCapacity = 128)
private val _allEvents = MutableSharedFlow<HostGatewayEvent>(extraBufferCapacity = 128)
val allEvents: SharedFlow<HostGatewayEvent> = _allEvents.asSharedFlow()
init {
@ -89,7 +90,7 @@ class HermesConnectionManager(
fun getOrCreateRuntime(host: HermesHost): HermesHostRuntime {
return runtimes.computeIfAbsent(host.id) {
val rt = runtimeFactory(host)
val rt = runtimeFactory(scope, host)
// Forward events
scope.launch {
rt.events.collect { event ->

View file

@ -0,0 +1,44 @@
package app.hermes.mobile.core.network
import app.hermes.mobile.core.model.GatewayEvent
import app.hermes.mobile.core.model.GatewayEvent.MessageDeltaEvent
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.runCurrent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EventStreamReplayTest {
@Test
fun testNoReplay() = runTest {
val client = JsonRpcGatewayClient(scope = this)
// Emit first event BEFORE subscribing
client.handleIncomingMessage("{\"jsonrpc\":\"2.0\",\"method\":\"event\",\"params\":{\"type\":\"message.delta\",\"session_id\":\"s1\",\"payload\":{\"message_id\":\"m1\",\"delta\":\"Hello\"}}}")
runCurrent()
val events = mutableListOf<GatewayEvent>()
val job = launch {
client.events.collect { events.add(it) }
}
runCurrent()
// Should not have received the first event
assertTrue("Events should be empty since we subscribed after the first emission", events.isEmpty())
// Emit second event AFTER subscribing
client.handleIncomingMessage("{\"jsonrpc\":\"2.0\",\"method\":\"event\",\"params\":{\"type\":\"message.delta\",\"session_id\":\"s1\",\"payload\":{\"message_id\":\"m1\",\"delta\":\" World\"}}}")
runCurrent()
assertEquals("Should have exactly 1 event", 1, events.size)
val event = events[0] as GatewayEvent.MessageDeltaEvent
assertEquals(" World", event.delta)
job.cancel()
}
}

View file

@ -6,6 +6,9 @@ import app.hermes.mobile.core.model.RuntimeSessionId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
@ -123,12 +126,18 @@ class JsonRpcGatewayClientTest {
)
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
// Start subscription BEFORE connecting, so we don't miss the event
val eventDeferred = async(start = CoroutineStart.UNDISPATCHED) {
withTimeout(5000) {
client.events.first { it is GatewayEvent.MessageDeltaEvent }
}
}
client.connect(wsUrl, allowCleartext = true)
client.awaitGatewayReady(5000)
val event = withTimeout(5000) {
client.events.first { it is GatewayEvent.MessageDeltaEvent }
}
val event = eventDeferred.await()
assertTrue(event is GatewayEvent.MessageDeltaEvent)
val deltaEvent = event as GatewayEvent.MessageDeltaEvent

View file

@ -59,7 +59,17 @@ class ApprovalRoutingTest {
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
scope = CoroutineScope(testDispatcher),
runtimeFactory = { parentScope, host ->
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
HermesHostRuntime(
initialHost = host,
restClient = app.hermes.mobile.core.network.HermesRestClient(),
gatewayClient = JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault,
scope = childScope
)
}
)
sessionRepo = UnifiedSessionRepository(

View file

@ -3,6 +3,7 @@ package app.hermes.mobile.core.repository
import app.hermes.mobile.core.model.*
import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.runtime.HermesHostRuntime
import app.hermes.mobile.core.security.InMemoryTokenVault
@ -53,7 +54,17 @@ class MultiHostConcurrencyExecutionTest {
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
scope = CoroutineScope(testDispatcher),
runtimeFactory = { parentScope, host ->
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
app.hermes.mobile.core.runtime.HermesHostRuntime(
initialHost = host,
restClient = app.hermes.mobile.core.network.HermesRestClient(),
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault,
scope = childScope
)
}
)
sessionRepo = UnifiedSessionRepository(
@ -664,8 +675,10 @@ class MultiHostConcurrencyExecutionTest {
scope = CoroutineScope(Dispatchers.Default)
)
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = host1Id)
val runtime = testConnectionManager.getRuntime(host1Id)!!
val host1 = HermesHost(id = HermesHostId("host-windows"), displayName = "Server", baseUrl = wsUrl, allowCleartext = true, lastKnownStatus = HostStatus.ONLINE)
testConnectionManager.addHost(host1)
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = HermesHostId("host-windows"))
val runtime = testConnectionManager.getRuntime(HermesHostId("host-windows"))!!
// Initial connect
runtime.connect()

View file

@ -5,6 +5,7 @@ import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.runtime.HermesConnectionManager
import app.hermes.mobile.core.runtime.HermesHostRuntime
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
@ -49,7 +50,17 @@ class UnifiedSessionRepositoryTest {
connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
scope = CoroutineScope(testDispatcher)
scope = CoroutineScope(testDispatcher),
runtimeFactory = { parentScope, host ->
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
app.hermes.mobile.core.runtime.HermesHostRuntime(
initialHost = host,
restClient = app.hermes.mobile.core.network.HermesRestClient(),
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault,
scope = childScope
)
}
)
repository = UnifiedSessionRepository(

View file

@ -0,0 +1,55 @@
package app.hermes.mobile.core.runtime
import app.hermes.mobile.core.model.HermesHost
import app.hermes.mobile.core.model.HermesHostId
import app.hermes.mobile.core.model.HostStatus
import app.hermes.mobile.core.network.HermesRestClient
import app.hermes.mobile.core.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertSame
import org.junit.Test
class AppLifecycleTest {
@Test
fun testSingleHostIdCorrespondsToAtMostOneLiveRuntime() = runTest {
val hostDao = FakeHostDao()
val tokenVault = InMemoryTokenVault()
val restClient = HermesRestClient()
val connectionManager = HermesConnectionManager(
hostDao = hostDao,
tokenVault = tokenVault,
restClient = restClient,
scope = backgroundScope,
runtimeFactory = { parentScope, host ->
val childScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + kotlinx.coroutines.test.StandardTestDispatcher(testScheduler))
app.hermes.mobile.core.runtime.HermesHostRuntime(
initialHost = host,
restClient = restClient,
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault,
scope = childScope
)
}
)
val host = HermesHost(
id = HermesHostId("h1"),
displayName = "Host 1",
baseUrl = "http://host1.com",
allowCleartext = true,
enabled = true,
lastSeenAt = 0L,
lastKnownStatus = HostStatus.OFFLINE
)
val runtime1 = connectionManager.getOrCreateRuntime(host)
val runtime2 = connectionManager.getOrCreateRuntime(host)
val runtime3 = connectionManager.getRuntime(host.id)
assertSame(runtime1, runtime2)
assertSame(runtime1, runtime3)
}
}