From 3e012dbf3148e969e6d2a5177825114158c31fe0 Mon Sep 17 00:00:00 2001 From: Hy4ri Date: Fri, 11 Sep 2026 00:03:54 +0300 Subject: [PATCH] refactor(chat): extract rpc request tracker with tests --- .../ui/chat/ChatRpcRequestTracker.kt | 111 ++++++++++++++++++ .../ui/chat/ChatRpcRequestTrackerTest.kt | 94 +++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt new file mode 100644 index 00000000..8492ab54 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTracker.kt @@ -0,0 +1,111 @@ +package com.m57.hermescontrol.ui.chat + +import java.util.concurrent.ConcurrentHashMap + +/** + * Tracks an in-flight RPC request associated with a specific session generation + * and resume sequence to guard against stale results/errors from previous sessions. + */ +data class TrackedSessionRequest( + val generation: Long, + val resumeSequence: Long = 0L, + val sessionId: String? = null, +) + +/** + * Owns RPC id-to-method mapping, session request generations, hydration sequences, + * and staleness detection. + * + * Extracted behavior-preservingly from [ChatViewModel] to ensure deterministic + * RPC tracking across session creation, switches, resumes, and reconnects. + */ +class ChatRpcRequestTracker( + private val getCurrentSessionId: () -> String?, +) { + /** Maps an in-flight RPC id to its method for UI error labeling. */ + private val idToMethod = ConcurrentHashMap() + private val sessionRequestById = ConcurrentHashMap() + + var sessionGeneration: Long = 0L + private set + + var resumeRequestSequence: Long = 0L + private set + var activeResumeRequestSequence: Long = 0L + private set + + var hydrationRequestSequence: Long = 0L + private set + var activeHydrationRequestSequence: Long = 0L + private set + + var resumedGeneration: Long = -1L + var hydratedGeneration: Long = -1L + + fun nextSessionGeneration(): Long { + sessionGeneration++ + return sessionGeneration + } + + fun nextResumeSequence(): Long { + resumeRequestSequence++ + activeResumeRequestSequence = resumeRequestSequence + return resumeRequestSequence + } + + fun nextHydrationSequence(): Long { + hydrationRequestSequence++ + activeHydrationRequestSequence = hydrationRequestSequence + return hydrationRequestSequence + } + + fun trackRequest( + id: String, + method: String, + ) { + idToMethod[id] = method + } + + fun trackSessionRequest( + id: String, + method: String, + generation: Long, + resumeSequence: Long = 0L, + sessionId: String? = null, + ) { + sessionRequestById[id] = TrackedSessionRequest(generation, resumeSequence, sessionId) + trackRequest(id, method) + } + + fun isCurrentSessionRequest( + sessionId: String, + generation: Long, + ): Boolean = generation == sessionGeneration && sessionId == getCurrentSessionId() + + fun isCurrentHydration( + sessionId: String, + generation: Long, + requestSequence: Long, + ): Boolean = requestSequence == activeHydrationRequestSequence && isCurrentSessionRequest(sessionId, generation) + + fun isStaleSessionRequest(id: String): Boolean = sessionRequestById[id]?.let(::isStaleSessionRequest) == true + + fun isStaleSessionRequest(request: TrackedSessionRequest): Boolean = + request.generation != sessionGeneration || + (request.sessionId != null && request.sessionId != getCurrentSessionId()) || + (request.resumeSequence != 0L && request.resumeSequence != activeResumeRequestSequence) + + fun removeMethod(id: String): String? = idToMethod.remove(id) + + fun removeSessionRequest(id: String): TrackedSessionRequest? = sessionRequestById.remove(id) + + fun forgetRequest(id: String) { + idToMethod.remove(id) + sessionRequestById.remove(id) + } + + fun resetSessionStateTracking() { + resumedGeneration = -1L + hydratedGeneration = -1L + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt new file mode 100644 index 00000000..78eac772 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatRpcRequestTrackerTest.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatRpcRequestTrackerTest { + private var currentSessionId: String? = "session-a" + private val tracker = ChatRpcRequestTracker { currentSessionId } + + @Test + fun trackRequest_andRemoveMethod() { + tracker.trackRequest("req-1", "session.create") + assertEquals("session.create", tracker.removeMethod("req-1")) + assertNull(tracker.removeMethod("req-1")) + } + + @Test + fun generationIncrement_detectsStaleGenerations() { + val gen1 = tracker.nextSessionGeneration() + assertEquals(1L, gen1) + + tracker.trackSessionRequest( + id = "req-resume-1", + method = "session.resume", + generation = gen1, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-resume-1")) + + val gen2 = tracker.nextSessionGeneration() + assertEquals(2L, gen2) + assertTrue(tracker.isStaleSessionRequest("req-resume-1")) + } + + @Test + fun sessionMismatch_marksRequestStale() { + val gen = tracker.nextSessionGeneration() + tracker.trackSessionRequest( + id = "req-2", + method = "session.resume", + generation = gen, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-2")) + + currentSessionId = "session-b" + assertTrue(tracker.isStaleSessionRequest("req-2")) + } + + @Test + fun resumeSequence_marksSupersededResumeStale() { + val gen = tracker.nextSessionGeneration() + val seq1 = tracker.nextResumeSequence() + tracker.trackSessionRequest( + id = "req-resume-seq1", + method = "session.resume", + generation = gen, + resumeSequence = seq1, + sessionId = "session-a", + ) + assertFalse(tracker.isStaleSessionRequest("req-resume-seq1")) + + val seq2 = tracker.nextResumeSequence() + assertEquals(2L, seq2) + assertTrue(tracker.isStaleSessionRequest("req-resume-seq1")) + } + + @Test + fun hydrationSequence_guardsActiveHydration() { + val gen = tracker.nextSessionGeneration() + val hyd1 = tracker.nextHydrationSequence() + assertTrue(tracker.isCurrentHydration("session-a", gen, hyd1)) + + val hyd2 = tracker.nextHydrationSequence() + assertFalse(tracker.isCurrentHydration("session-a", gen, hyd1)) + assertTrue(tracker.isCurrentHydration("session-a", gen, hyd2)) + } + + @Test + fun forgetRequest_removesBothTrackingEntries() { + tracker.trackSessionRequest( + id = "req-forget", + method = "session.resume", + generation = tracker.sessionGeneration, + sessionId = "session-a", + ) + tracker.forgetRequest("req-forget") + assertNull(tracker.removeMethod("req-forget")) + assertNull(tracker.removeSessionRequest("req-forget")) + } +}