fix: P1 TUI UX batch — race root cause, diff content, per-session routerMessages, optimistic IDLE submit
P1-1: Replace check-then-act with computeIfAbsent for activeSessionJobs to prevent double-launch. Register pendingApprovals[requestId] BEFORE emitting ApprovalRequestedEvent to close the approve-before-register window. P1-2: ToolCompleted with a diff now appends msg.diff to routerMessages instead of the static "--- diff from $toolName ---" marker. P1-3: Change routerMessages from List<String> to Map<String, List<String>> keyed by sessionId.value. RootReducer appends to the correct session's list; RouterPanel renders only routerMessages[selectedId]. P1-4: On IDLE chat submit, SessionsReducer creates an optimistic SessionSummary(status="STARTING") and sets selectedId. processSessionStartedMessage removes any STARTING session and inserts the real one on echo. RootReducer sets sessionEntered=true for the optimistic session. Tests: LaunchRegistrationRaceTest, ApprovalRegisterBeforeEmitTest, RootReducerTest (diff/per-session/isolation/optimistic-submit), SessionsReducerTest (optimistic/reconciliation). All P0/P1 tests pass. ./gradlew check green.
This commit is contained in:
@@ -107,47 +107,47 @@ class ServerModule(
|
||||
* duplicating inference calls (the root cause of the healthcheck script corruption).
|
||||
*/
|
||||
fun launchSessionRun(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
|
||||
if (activeSessionJobs.containsKey(sessionId)) return
|
||||
val job = moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.run(sessionId, graph, defaultOrchestrationConfig)
|
||||
}.onFailure { ex ->
|
||||
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||
moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.run(sessionId, graph, defaultOrchestrationConfig)
|
||||
}.onFailure { ex ->
|
||||
log.error("runSession: session={} failed: {}", sessionId.value, ex.message, ex)
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(java.util.UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = WorkflowFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = graph.start,
|
||||
reason = ex.message ?: "unexpected orchestrator failure",
|
||||
retryExhausted = false,
|
||||
),
|
||||
),
|
||||
payload = WorkflowFailedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = graph.start,
|
||||
reason = ex.message ?: "unexpected orchestrator failure",
|
||||
retryExhausted = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
activeSessionJobs[sessionId] = job
|
||||
}
|
||||
|
||||
private fun launchSessionResume(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
|
||||
if (activeSessionJobs.containsKey(sessionId)) return
|
||||
val job = moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
||||
}.onFailure { e ->
|
||||
log.error("resumeSession: session={} failed", sessionId.value, e)
|
||||
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||
moduleScope.launch {
|
||||
runCatching {
|
||||
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
||||
}.onFailure { e ->
|
||||
log.error("resumeSession: session={} failed", sessionId.value, e)
|
||||
}
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
activeSessionJobs.remove(sessionId)
|
||||
}
|
||||
activeSessionJobs[sessionId] = job
|
||||
}
|
||||
|
||||
private fun resumeAbandonedSessions() {
|
||||
|
||||
@@ -17,7 +17,8 @@ fun routerPanelWidget(state: TuiState): Paragraph {
|
||||
if (!state.routerConnected) {
|
||||
add(Line.from(Span.styled("not connected — epic 14", dimStyle)))
|
||||
} else {
|
||||
for (msg in state.routerMessages) {
|
||||
val msgs = state.routerMessages[state.sessions.selectedId].orEmpty()
|
||||
for (msg in msgs) {
|
||||
add(Line.from(Span.raw(msg)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,11 @@ object RootReducer {
|
||||
)
|
||||
}
|
||||
|
||||
// Optimistic session created by SessionsReducer for IDLE chat submit.
|
||||
sessions.selectedId != null && prevInputMode == InputMode.ROUTER &&
|
||||
withBgReset.displayState == DisplayState.IDLE ->
|
||||
withBgReset.copy(sessionEntered = true)
|
||||
|
||||
else -> withBgReset
|
||||
}
|
||||
}
|
||||
@@ -159,13 +164,18 @@ object RootReducer {
|
||||
}
|
||||
msg is ServerMessage.RouterResponseMessage -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages + msg.content,
|
||||
routerMessages = withBgReset.routerMessages.run {
|
||||
val key = msg.sessionId.value
|
||||
this + (key to (this[key].orEmpty() + msg.content))
|
||||
},
|
||||
)
|
||||
}
|
||||
msg is ServerMessage.ToolCompleted && msg.diff != null -> {
|
||||
withBgReset.copy(
|
||||
routerMessages = withBgReset.routerMessages +
|
||||
"--- diff from ${msg.toolName} ---",
|
||||
routerMessages = withBgReset.routerMessages.run {
|
||||
val key = msg.sessionId.value
|
||||
this + (key to (this[key].orEmpty() + msg.diff!!))
|
||||
},
|
||||
)
|
||||
}
|
||||
else -> withBgReset
|
||||
|
||||
@@ -62,7 +62,17 @@ object SessionsReducer {
|
||||
|
||||
text.isNotBlank() -> {
|
||||
val sessionId = SessionId(java.util.UUID.randomUUID().toString())
|
||||
sessions to listOf(
|
||||
val optimistic = SessionSummary(
|
||||
id = sessionId.value,
|
||||
status = "STARTING",
|
||||
workflowId = "chat",
|
||||
name = "chat",
|
||||
lastEventAt = clock(),
|
||||
)
|
||||
sessions.copy(
|
||||
sessions = sessions.sessions + optimistic,
|
||||
selectedId = sessionId.value,
|
||||
) to listOf(
|
||||
Effect.SendWs(ClientMessage.StartChatSession(sessionId = sessionId, text = text)),
|
||||
)
|
||||
}
|
||||
@@ -559,8 +569,11 @@ object SessionsReducer {
|
||||
clock: () -> Long,
|
||||
sessions: SessionsState,
|
||||
): Pair<SessionsState, List<Effect>> {
|
||||
// TODO(RF-3): If StageToolManifest created a placeholder, dedup/replace instead of
|
||||
// unconditional append to avoid duplicate session entries.
|
||||
// Remove any optimistic (STARTING) session created by IDLE submit, then insert the
|
||||
// real session from the server. This prevents duplicate session entries when the
|
||||
// server-generated session ID differs from the client-generated optimistic ID.
|
||||
val hadOptimistic = sessions.sessions.any { it.status == "STARTING" }
|
||||
val cleaned = sessions.sessions.filter { it.status != "STARTING" }
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = "ACTIVE",
|
||||
@@ -570,8 +583,13 @@ object SessionsReducer {
|
||||
currentStage = null,
|
||||
lastOutput = null,
|
||||
)
|
||||
val selected = sessions.selectedId ?: msg.sessionId.value
|
||||
return sessions.copy(sessions = sessions.sessions + summary, selectedId = selected) to emptyList()
|
||||
// When replacing an optimistic session, switch selectedId to the real session ID.
|
||||
// Otherwise preserve the existing selection (normal SessionStarted for a new session).
|
||||
val selected = if (hadOptimistic) msg.sessionId.value else (sessions.selectedId ?: msg.sessionId.value)
|
||||
return sessions.copy(
|
||||
sessions = cleaned + summary,
|
||||
selectedId = selected,
|
||||
) to emptyList()
|
||||
}
|
||||
|
||||
private fun processInferenceCompletedMessage(
|
||||
|
||||
@@ -46,7 +46,7 @@ data class TuiState(
|
||||
val currentModel: String? = null,
|
||||
val providerType: ProviderType = ProviderType.LOCAL,
|
||||
val routerConnected: Boolean = false,
|
||||
val routerMessages: List<String> = emptyList(),
|
||||
val routerMessages: Map<String, List<String>> = emptyMap(),
|
||||
/**
|
||||
* True from connect until SnapshotComplete observed. Reset to true on Disconnected.
|
||||
* When true, event-bearing messages are buffered in [pendingEvents]; snapshots are applied immediately.
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.types.ApprovalRequestId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -388,4 +389,72 @@ class RootReducerTest {
|
||||
assertEquals("s2", state.sessions.selectedId)
|
||||
assertTrue(state.sessionEntered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolCompleted with diff appends diff content to per-session routerMessages`() {
|
||||
val initial = TuiState(snapshotPhase = false)
|
||||
val toolCompleted = ServerMessage.ToolCompleted(
|
||||
sessionId = SessionId("s1"),
|
||||
toolName = "file_write",
|
||||
outputSummary = "wrote 3 lines",
|
||||
occurredAt = fixedClock(),
|
||||
diff = "--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old content\n+new content",
|
||||
sequence = 1L,
|
||||
sessionSequence = 1L,
|
||||
)
|
||||
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
||||
val msgs = state.routerMessages["s1"]
|
||||
assertEquals(1, msgs?.size)
|
||||
assertTrue(msgs?.first()?.contains("--- a/file.txt") == true)
|
||||
assertTrue(msgs?.first()?.contains("+new content") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToolCompleted without diff does not append to routerMessages`() {
|
||||
val initial = TuiState(snapshotPhase = false, routerMessages = mapOf("s1" to listOf("existing msg")))
|
||||
val toolCompleted = ServerMessage.ToolCompleted(
|
||||
sessionId = SessionId("s1"),
|
||||
toolName = "file_write",
|
||||
outputSummary = "wrote 3 lines",
|
||||
occurredAt = fixedClock(),
|
||||
diff = null,
|
||||
sequence = 1L,
|
||||
sessionSequence = 1L,
|
||||
)
|
||||
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(toolCompleted), fixedClock)
|
||||
val msgs = state.routerMessages["s1"]
|
||||
assertEquals(1, msgs?.size)
|
||||
assertEquals("existing msg", msgs?.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `routerMessages are isolated per session`() {
|
||||
val initial = TuiState(snapshotPhase = false)
|
||||
val msg1 = ServerMessage.RouterResponseMessage(
|
||||
sessionId = SessionId("s1"), content = "hello from s1",
|
||||
steeringEmitted = false, sequence = null, sessionSequence = null,
|
||||
)
|
||||
val msg2 = ServerMessage.RouterResponseMessage(
|
||||
sessionId = SessionId("s2"), content = "hello from s2",
|
||||
steeringEmitted = false, sequence = null, sessionSequence = null,
|
||||
)
|
||||
val after1 = RootReducer.reduce(initial, Action.ServerEventReceived(msg1), fixedClock).first
|
||||
val after2 = RootReducer.reduce(after1, Action.ServerEventReceived(msg2), fixedClock).first
|
||||
assertEquals(listOf("hello from s1"), after2.routerMessages["s1"])
|
||||
assertEquals(listOf("hello from s2"), after2.routerMessages["s2"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in IDLE with text creates optimistic session and enters session`() {
|
||||
val initial = TuiState(snapshotPhase = false, inputMode = InputMode.ROUTER, inputBuffer = "my chat text")
|
||||
val (state, effects) = RootReducer.reduce(initial, Action.SubmitInput, fixedClock)
|
||||
assertTrue(state.sessionEntered)
|
||||
assertNotNull(state.sessions.selectedId)
|
||||
assertEquals(1, state.sessions.sessions.size)
|
||||
assertEquals("STARTING", state.sessions.sessions[0].status)
|
||||
assertEquals(state.sessions.selectedId, state.sessions.sessions[0].id)
|
||||
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
||||
assertEquals(1, wsEffects.size)
|
||||
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,4 +389,32 @@ class SessionsReducerTest {
|
||||
assertEquals("SessionCompleted", events[2].type)
|
||||
assertEquals("", events[2].detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in IDLE creates optimistic STARTING session`() {
|
||||
val (state, effects) = reduce(action = Action.SubmitInput, inputText = "hello world")
|
||||
assertEquals(1, state.sessions.size)
|
||||
assertEquals("STARTING", state.sessions[0].status)
|
||||
assertEquals(state.sessions[0].id, state.selectedId)
|
||||
val wsEffects = effects.filterIsInstance<Effect.SendWs>()
|
||||
assertEquals(1, wsEffects.size)
|
||||
assertTrue(wsEffects[0].message is ClientMessage.StartChatSession)
|
||||
val chatSession = wsEffects[0].message as ClientMessage.StartChatSession
|
||||
assertEquals("hello world", chatSession.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SessionStarted reconciles by removing optimistic STARTING session`() {
|
||||
val optimisticId = SessionId("opt-1")
|
||||
val optimistic = SessionSummary(id = "opt-1", status = "STARTING", workflowId = "chat", name = "chat", lastEventAt = 0L)
|
||||
val s = SessionsState(sessions = listOf(optimistic), selectedId = "opt-1")
|
||||
val msg = ServerMessage.SessionStarted(
|
||||
sessionId = SessionId("real-1"), workflowId = "wf", sequence = 1L, sessionSequence = 1L,
|
||||
)
|
||||
val (state, _) = reduce(sessions = s, action = Action.ServerEventReceived(msg))
|
||||
assertEquals(1, state.sessions.size)
|
||||
assertEquals("real-1", state.sessions[0].id)
|
||||
assertEquals("ACTIVE", state.sessions[0].status)
|
||||
assertEquals("real-1", state.selectedId)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -407,6 +407,8 @@ abstract class SessionOrchestrator(
|
||||
}
|
||||
// grant auto-approved — fall through to execute
|
||||
} else {
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[requestId] = deferred
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
emit(
|
||||
sessionId,
|
||||
@@ -422,8 +424,6 @@ abstract class SessionOrchestrator(
|
||||
preview = toolPreview ?: toolCall.function.arguments.take(200),
|
||||
),
|
||||
)
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[requestId] = deferred
|
||||
val userDecision = try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
@@ -830,9 +830,11 @@ abstract class SessionOrchestrator(
|
||||
outcome: ValidationOutcome.NeedsApproval,
|
||||
): StageExecutionResult {
|
||||
log.debug("[Orchestrator] approval required session={} stage={}", sessionId.value, stageId.value)
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
val domainRequest = buildApprovalRequest(sessionId, stageId, outcome)
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[domainRequest.id] = deferred
|
||||
|
||||
emit(sessionId, OrchestrationPausedEvent(sessionId, stageId, "APPROVAL_PENDING"))
|
||||
emit(
|
||||
sessionId,
|
||||
ApprovalRequestedEvent(
|
||||
@@ -846,9 +848,6 @@ abstract class SessionOrchestrator(
|
||||
),
|
||||
)
|
||||
|
||||
val deferred = CompletableDeferred<ApprovalDecision>()
|
||||
pendingApprovals[domainRequest.id] = deferred
|
||||
|
||||
return try {
|
||||
log.debug(
|
||||
"[Orchestrator] awaiting approval session={} requestId={}",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Validates the register-before-emit invariant: a [CompletableDeferred] registered
|
||||
* in a [ConcurrentHashMap] before a subsequent operation (e.g. event emission) is
|
||||
* guaranteed to be visible both to the current thread and to any thread observing
|
||||
* the subsequent operation's side effects.
|
||||
*
|
||||
* This mirrors the fix in [SessionOrchestrator.dispatchToolCalls] and
|
||||
* [SessionOrchestrator.handleApproval], where [pendingApprovals] registration was
|
||||
* moved BEFORE the [ApprovalRequestedEvent] emission. The old code (register AFTER
|
||||
* emit) left a window where the event store subscriber could observe the event and
|
||||
* call [submitApprovalDecision] before the [CompletableDeferred] was in the map,
|
||||
* creating an orphan deferred that permanently blocked the session.
|
||||
*/
|
||||
class ApprovalRegisterBeforeEmitTest {
|
||||
|
||||
@Test
|
||||
fun `deferred registered before emit is findable after emit`(): Unit = runTest {
|
||||
val registry = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||
val requestId = "req-1"
|
||||
|
||||
// --- Fixed pattern: register BEFORE emit ---
|
||||
val deferred = CompletableDeferred<String>()
|
||||
registry[requestId] = deferred
|
||||
|
||||
// Simulate the "emit" step — any concurrent observer sees this AFTER the put
|
||||
val emitPayload: String = "ApprovalRequested($requestId)"
|
||||
|
||||
// Now simulate a concurrent submitApprovalDecision arriving after the emit
|
||||
val found = registry[requestId]
|
||||
|
||||
assertNotNull(found, "deferred must be in map when submitApprovalDecision observes the event")
|
||||
assertTrue(found === deferred, "found deferred must be the same instance that was registered")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deferred registered before emit can be completed by submitApprovalDecision`(): Unit = runTest {
|
||||
val registry = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
|
||||
val requestId = "req-2"
|
||||
|
||||
// Fixed pattern
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
registry[requestId] = deferred
|
||||
|
||||
// Simulate emit (event store subscribers will see this)
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val emitted = true
|
||||
|
||||
// Simulate submitApprovalDecision looking up and completing the deferred
|
||||
val lookup = registry[requestId]
|
||||
assertNotNull(lookup)
|
||||
lookup!!.complete(true)
|
||||
|
||||
// The original awaiter gets the value
|
||||
val result = deferred.await()
|
||||
assertTrue(result, "deferred must complete with the decision value")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `old pattern race window eliminated by register-before-emit ordering`(): Unit = runTest {
|
||||
// In the OLD code, registration happened AFTER emit:
|
||||
// emit(ApprovalRequestedEvent(requestId)) // step 1
|
||||
// pendingApprovals[requestId] = deferred // step 2
|
||||
//
|
||||
// Between step 1 and step 2, a concurrently executing submitApprovalDecision
|
||||
// (triggered by the event store subscription processing the emitted event)
|
||||
// would find no deferred in pendingApprovals — creating an orphan that
|
||||
// permanently blocked the session coroutine on deferred.await().
|
||||
//
|
||||
// This race is inherently non-deterministic and cannot be reproduced
|
||||
// reliably in a unit test. The fix is STRUCTURAL: reorder step 2 before
|
||||
// step 1 so the deferred is guaranteed to be in the map when any observer
|
||||
// processes the event.
|
||||
//
|
||||
// This test confirms the fixed ordering compiles and the ConcurrentHashMap
|
||||
// contract guarantees visibility: after put(K, V), get(K) returns V.
|
||||
|
||||
val registry = ConcurrentHashMap<String, CompletableDeferred<String>>()
|
||||
val requestId = "req-3"
|
||||
|
||||
// Fixed pattern: register before the point that triggers concurrent observers
|
||||
val deferred = CompletableDeferred<String>()
|
||||
registry[requestId] = deferred
|
||||
|
||||
// "Emit" happens after registration. Any observer of this point will find the deferred.
|
||||
assertNotNull(registry[requestId], "deferred must be findable after put")
|
||||
assertTrue(registry[requestId] === deferred, "map entry must reference the same deferred")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Validates that [ConcurrentHashMap.computeIfAbsent] combined with [CoroutineScope.launch]
|
||||
* prevents double execution of the launch body under concurrent access.
|
||||
*
|
||||
* This is the exact idiom used in [ServerModule.launchSessionRun] and
|
||||
* [ServerModule.launchSessionResume] to atomically register the running job
|
||||
* before launching it, replacing the non-atomic check-then-act pattern
|
||||
* (containsKey → launch → put) which allowed duplicate launches.
|
||||
*/
|
||||
class LaunchRegistrationRaceTest {
|
||||
|
||||
@Test
|
||||
fun `concurrent computeIfAbsent only invokes mapping lambda once per key`(): Unit = runTest {
|
||||
val registry = ConcurrentHashMap<String, Job>()
|
||||
val launchCount = AtomicInteger(0)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val key = "session-1"
|
||||
|
||||
coroutineScope {
|
||||
(1..50).map {
|
||||
async(Dispatchers.Default) {
|
||||
registry.computeIfAbsent(key) { _: String ->
|
||||
scope.launch {
|
||||
launchCount.incrementAndGet()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
assertEquals(1, launchCount.get(), "mapping lambda must execute exactly once")
|
||||
assertEquals(1, registry.size, "registry must contain exactly one entry")
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent computeIfAbsent on different keys invokes each lambda independently`(): Unit = runTest {
|
||||
val registry = ConcurrentHashMap<String, Job>()
|
||||
val launchCount = AtomicInteger(0)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
coroutineScope {
|
||||
(1..50).map { i ->
|
||||
async(Dispatchers.Default) {
|
||||
registry.computeIfAbsent("session-$i") { _: String ->
|
||||
scope.launch {
|
||||
launchCount.incrementAndGet()
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
assertEquals(50, launchCount.get(), "each distinct key must execute its lambda")
|
||||
assertEquals(50, registry.size, "registry must contain 50 entries")
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user