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 // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.12")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
testImplementation("app.cash.turbine:turbine:1.2.0") testImplementation("app.cash.turbine:turbine:1.2.0")

View file

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name=".HermesApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@android:drawable/sym_def_app_icon" android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name" 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) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
val db = HermesDatabase.getInstance(applicationContext) val container = (applicationContext as HermesApplication).container
val db = container.db
val hostDao = db.hostDao() 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 // Migrate legacy connections from DataStore if present
lifecycleScope.launch { lifecycleScope.launch {
MigrationHelper.migrateLegacyConnections(applicationContext, hostDao) MigrationHelper.migrateLegacyConnections(applicationContext, hostDao)
} }
val tokenVault = container.tokenVault
val connectionManager = HermesConnectionManager( val pkceAuthManager = container.pkceAuthManager
hostDao = hostDao, val connectionManager = container.connectionManager
tokenVault = tokenVault, val unifiedSessionRepo = container.unifiedSessionRepo
restClient = restClient
)
val unifiedSessionRepo = UnifiedSessionRepository(
connectionManager = connectionManager,
sessionDao = sessionDao
)
setContent { setContent {
HermesAndroidTheme { HermesAndroidTheme {

View file

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

View file

@ -26,13 +26,14 @@ class HermesConnectionManager(
val tokenVault: TokenVault, val tokenVault: TokenVault,
val restClient: HermesRestClient = HermesRestClient(), val restClient: HermesRestClient = HermesRestClient(),
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), 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( HermesHostRuntime(
initialHost = host, initialHost = host,
restClient = restClient, restClient = restClient,
gatewayClient = JsonRpcGatewayClient(scope = scope), gatewayClient = JsonRpcGatewayClient(scope = childScope),
tokenVault = tokenVault, tokenVault = tokenVault,
scope = scope scope = childScope
) )
} }
) { ) {
@ -44,7 +45,7 @@ class HermesConnectionManager(
private val _activeHostId = MutableStateFlow<HermesHostId?>(null) private val _activeHostId = MutableStateFlow<HermesHostId?>(null)
val activeHostId: StateFlow<HermesHostId?> = _activeHostId.asStateFlow() 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() val allEvents: SharedFlow<HostGatewayEvent> = _allEvents.asSharedFlow()
init { init {
@ -89,7 +90,7 @@ class HermesConnectionManager(
fun getOrCreateRuntime(host: HermesHost): HermesHostRuntime { fun getOrCreateRuntime(host: HermesHost): HermesHostRuntime {
return runtimes.computeIfAbsent(host.id) { return runtimes.computeIfAbsent(host.id) {
val rt = runtimeFactory(host) val rt = runtimeFactory(scope, host)
// Forward events // Forward events
scope.launch { scope.launch {
rt.events.collect { event -> 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.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.async
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
@ -123,12 +126,18 @@ class JsonRpcGatewayClientTest {
) )
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws" 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.connect(wsUrl, allowCleartext = true)
client.awaitGatewayReady(5000) client.awaitGatewayReady(5000)
val event = withTimeout(5000) { val event = eventDeferred.await()
client.events.first { it is GatewayEvent.MessageDeltaEvent }
}
assertTrue(event is GatewayEvent.MessageDeltaEvent) assertTrue(event is GatewayEvent.MessageDeltaEvent)
val deltaEvent = event as GatewayEvent.MessageDeltaEvent val deltaEvent = event as GatewayEvent.MessageDeltaEvent

View file

@ -59,7 +59,17 @@ class ApprovalRoutingTest {
connectionManager = HermesConnectionManager( connectionManager = HermesConnectionManager(
hostDao = hostDao, hostDao = hostDao,
tokenVault = tokenVault, 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( 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.model.*
import app.hermes.mobile.core.network.ConnectionState import app.hermes.mobile.core.network.ConnectionState
import app.hermes.mobile.core.network.JsonRpcGatewayClient 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.HermesConnectionManager
import app.hermes.mobile.core.runtime.HermesHostRuntime import app.hermes.mobile.core.runtime.HermesHostRuntime
import app.hermes.mobile.core.security.InMemoryTokenVault import app.hermes.mobile.core.security.InMemoryTokenVault
@ -53,7 +54,17 @@ class MultiHostConcurrencyExecutionTest {
connectionManager = HermesConnectionManager( connectionManager = HermesConnectionManager(
hostDao = hostDao, hostDao = hostDao,
tokenVault = tokenVault, 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( sessionRepo = UnifiedSessionRepository(
@ -664,8 +675,10 @@ class MultiHostConcurrencyExecutionTest {
scope = CoroutineScope(Dispatchers.Default) scope = CoroutineScope(Dispatchers.Default)
) )
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = host1Id) val host1 = HermesHost(id = HermesHostId("host-windows"), displayName = "Server", baseUrl = wsUrl, allowCleartext = true, lastKnownStatus = HostStatus.ONLINE)
val runtime = testConnectionManager.getRuntime(host1Id)!! testConnectionManager.addHost(host1)
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = HermesHostId("host-windows"))
val runtime = testConnectionManager.getRuntime(HermesHostId("host-windows"))!!
// Initial connect // Initial connect
runtime.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.HermesRestClient
import app.hermes.mobile.core.network.JsonRpcGatewayClient import app.hermes.mobile.core.network.JsonRpcGatewayClient
import app.hermes.mobile.core.runtime.HermesConnectionManager 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.security.InMemoryTokenVault
import app.hermes.mobile.core.storage.FakeHostDao import app.hermes.mobile.core.storage.FakeHostDao
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
@ -49,7 +50,17 @@ class UnifiedSessionRepositoryTest {
connectionManager = HermesConnectionManager( connectionManager = HermesConnectionManager(
hostDao = hostDao, hostDao = hostDao,
tokenVault = tokenVault, 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( 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)
}
}