refactor(chat): extract rpc request tracker with tests
This commit is contained in:
@@ -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<String, String>()
|
||||||
|
private val sessionRequestById = ConcurrentHashMap<String, TrackedSessionRequest>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user