Fix WebSocket events being silently dropped on buffer overflow (#313)

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix ktlint formatting issues in HermesWsClient.kt

The formatting changes adhere to ktlint rules required by the CI pipeline:
- Removed a trailing comma in MutableSharedFlow initialization to avoid multiline expression wrapping errors.
- Sorted imports to be in lexicographical order.

These formatting changes fix the failing CI check.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow `extraBufferCapacity` from 64 to 256
and changes the `onBufferOverflow` policy to `BufferOverflow.DROP_OLDEST`
in `HermesWsClient.kt`. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting `DROP_OLDEST`, `tryEmit()` remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

* Fix WebSocket events being silently dropped on buffer overflow

Increases the MutableSharedFlow extraBufferCapacity from 64 to 256
and changes the onBufferOverflow policy to BufferOverflow.DROP_OLDEST
in HermesWsClient.kt. This fixes an issue where the slow collection
(e.g., due to DB writes) caused the buffer to fill up and silently drop
incoming real-time WebSocket events.

By setting DROP_OLDEST, tryEmit() remains functional without
suspending, allowing real-time events to be retained and prioritized.

Fixes #275

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-23 22:13:55 +03:00
1 parent 2c6e437a5b
commit eb71ae3a46
41 files changed
+666 -246

No files matched your search

@@ -9,14 +9,20 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -74,19 +80,42 @@ object HermesWsClient {
@Volatile
private var reconnectJob: Job? = null
// SEC-11: No certificate pinning is configured by default since the app
// intentionally uses HTTP for LAN and hosts are dynamic.
// CertificatePinner infrastructure is provided here if HTTPS is used with a known host.
private val certificatePinner = CertificatePinner.Builder().build()
private val okHttpClient =
OkHttpClient
.Builder()
.certificatePinner(certificatePinner)
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
.pingInterval(30, TimeUnit.SECONDS)
.build()
// ── Public observable stream ─────────────────────────────────────────
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
private val rawMessages =
MutableSharedFlow<String>(
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
val events: SharedFlow<WsEvent> =
rawMessages
.buffer()
.map { text ->
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text)
}
}
.flowOn(Dispatchers.IO)
.shareIn(wsScope, SharingStarted.Eagerly)
// ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -163,7 +192,8 @@ object HermesWsClient {
val url = AuthManager.wsUrl()
// B2 (Jun 18 2026, kanban t_8884db16): url contains the auth token
// as a query param — never stream to logcat in release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $url")
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
@@ -209,10 +239,6 @@ object HermesWsClient {
}
}
private fun emit(event: WsEvent) {
_events.tryEmit(event)
}
// ── Listener ─────────────────────────────────────────────────────────
private class WsListenerImpl : WebSocketListener() {
@@ -234,14 +260,7 @@ object HermesWsClient {
// AI reply tokens and tool output — never stream to logcat in
// release builds.
if (BuildConfig.DEBUG) Log.d(TAG, "← $text")
try {
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
val event = EventParser.parse(rpc, text)
emit(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
emit(WsEvent.Unknown(text))
}
rawMessages.tryEmit(text)
}
override fun onClosing(