* fix(ws): align RPC timeout to 120s + rejectAllPending on disconnect (fixes #526) Mobile's pending-request handling diverged from the desktop contract (apps/shared JsonRpcGatewayClient) in two ways that caused silent hangs: 1. Timeout was 30s while the desktop uses 120s (DEFAULT_REQUEST_TIMEOUT_MS), so legitimately long agent turns were pruned early. Align to 120s and use it for both the cleanup sweep and sendRpcAndAwait's per-call withTimeout. 2. The cleanup sweep only Log.w'd stale requests and dropped them — it never failed the CompletableDeferred, so sendRpcAndAwait callers awaiting a server that never answered HUNG FOREVER. And on disconnect/reconnect there was NO rejectAllPending, so in-flight awaited RPCs also hung until their own timeout. Changes (all in ChatViewModel.kt): - cleanup sweep now completeExceptionally()s the deferred AND surfaces a 'Request timed out' error for fire-and-forget RPCs (was silently dropped) - new rejectAllPending() fails + clears every in-flight pending deferred; wired into the disconnect-status collector and reconnect() - PENDING_REQUEST_TIMEOUT_MS = 120_000 (matches desktop); sendRpcAndAwait uses it instead of a hardcoded 30s Tests: 2 new ChatViewModelTest cases cover rejectAllPending on disconnect and on reconnect (no hang, VM stays usable). (Timeout-surfacing path shares the deferred-fail logic with handleRpcError; the 120s sweep can't run under the unit-test dispatcher's periodic-timer limitation, noted in PR.) Mirrors the desktop-app audit in /opt/hermes-agent. * fix(ws): aggregate pending-request cleanup into a single UI update (PR #541) Sourcery review (PR #541): startPendingRequestCleanup emitted one _uiState.update{} per stale request inside the loop, causing N separate emissions / redundant recompositions when many RPCs time out at once. Collect fire-and-forget methods and emit a single combined errorMessage update after the loop. rejectAllPending is intentionally left silent on disconnect/reconnect: those are transient socket events (the RPC often completes on the new socket), so surfacing a scary error banner is UX noise. Only the timeout path surfaces errors — that is a genuine failure. This matches the desktop JsonRpcGatewayClient.rejectAllPending semantics. * refactor(ws): move awaited-RPC pending/timeout layer into HermesWsClient (closes #526) Issue #526: port the JsonRpcGatewayClient.request() pattern into HermesWsClient. HermesWsClient now owns the in-flight map, per-call 120s timeout, resolve-on-RpcResult/RpcError, and rejectAllPending on disconnect/reconnect. ChatViewModel.sendRpcAndAwait simply delegates to wsClient.request(...).await(); its local pendingRequests map, PendingRequest, startPendingRequestCleanup loop and rejectAllPending are removed. Fire-and-forget send() is unchanged. --------- Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
1 parent
c5d1ef1c42
commit
def32f8f27
3 files changed
+199
-84
No files matched your search
@@ -120,8 +120,10 @@ class ChatViewModelTest {
|
||||
* Request ID sequence: GatewayReady triggers loadSessions (req-id-1),
|
||||
* fetchCommandCatalog (req-id-2), then createNewSession (req-id-3).
|
||||
*/
|
||||
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
|
||||
val viewModel = createViewModel()
|
||||
private suspend fun TestScope.createViewModelWithSession(
|
||||
startCleanup: Boolean = false,
|
||||
): Pair<ChatViewModel, String> {
|
||||
val viewModel = createViewModel(startCleanup)
|
||||
advanceUntilIdle()
|
||||
|
||||
mockConnectionStatus.value = ConnectionStatus.CONNECTED
|
||||
@@ -1243,4 +1245,68 @@ class ChatViewModelTest {
|
||||
viewModel.uiState.value.messages.any { it.content == "Here is an image" }
|
||||
assertTrue("Message should still be sent despite attachment read failure", sent)
|
||||
}
|
||||
|
||||
// ── Pending request timeout + rejectAllPending (issue #526) ───────────
|
||||
|
||||
/**
|
||||
* On disconnect (RECONNECTING) the ViewModel must run rejectAllPending
|
||||
* without throwing and stay usable — mirroring desktop
|
||||
* JsonRpcGatewayClient.rejectAllPending invoked on socket close. This is
|
||||
* what prevents callers awaiting a CompletableDeferred from hanging
|
||||
* across a socket drop.
|
||||
*/
|
||||
@Test
|
||||
fun testDisconnect_rejectsPendingWithoutError() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "rm",
|
||||
description = "Dangerous",
|
||||
patternKeys = null,
|
||||
sessionId = null,
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
viewModel.respondToApproval("approve")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Simulate socket drop → reconnecting (triggers rejectAllPending).
|
||||
mockConnectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
advanceUntilIdle()
|
||||
|
||||
// No exception propagated; VM remains usable.
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* viewModel.reconnect() calls rejectAllPending() before wsClient.disconnect(),
|
||||
* so any in-flight awaited RPC is failed fast instead of hanging until its
|
||||
* own timeout.
|
||||
*/
|
||||
@Test
|
||||
fun testReconnect_rejectsPendingWithoutError() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
mockEventsFlow.emit(
|
||||
WsEvent.ApprovalRequest(
|
||||
command = "rm",
|
||||
description = "Dangerous",
|
||||
patternKeys = null,
|
||||
sessionId = null,
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
viewModel.respondToApproval("approve")
|
||||
advanceUntilIdle()
|
||||
|
||||
// User-initiated reconnect must not throw / hang.
|
||||
viewModel.reconnect()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.errorMessage)
|
||||
verify { HermesWsClient.disconnect() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user