feat(effects-01): sequential effect dispatch in TuiApp

Replace per-effect launch with a single launch that iterates effects
sequentially via dispatchAll, closing the ordering race (I-E1 invariant).
Add EffectDispatchOrderTest proving sequential preserves order while
fire-and-forget does not.
This commit is contained in:
2026-05-25 16:49:10 +04:00
parent 9ff58b36ad
commit 5effe287d4
2 changed files with 64 additions and 2 deletions
@@ -9,6 +9,7 @@ import com.correx.apps.tui.components.toolsPanelWidget
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.input.KeyResolver
import com.correx.apps.tui.input.mapKey
import com.correx.apps.tui.reducer.Effect
import com.correx.apps.tui.reducer.EffectDispatcher
import com.correx.apps.tui.reducer.RootReducer
import com.correx.apps.tui.state.TuiState
@@ -44,6 +45,8 @@ fun main(args: Array<String>) {
val effectScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
TuiRunner.create().use { runner ->
val dispatcher = EffectDispatcher(ws) { runner.quit() }
fun dispatch(action: Action) {
val (next, effects) = RootReducer.reduce(state, action)
log.debug("got connection state after reducers: {}", next.connection)
@@ -53,9 +56,12 @@ fun main(args: Array<String>) {
next.sessions.sessions.firstOrNull { it.status != "COMPLETED" },
)
state = next
effects.forEach { effect ->
if (effects.isNotEmpty()) {
effectScope.launch {
EffectDispatcher(ws) { runner.quit() }.dispatch(effect)
// Sequential: each effect awaits the previous before starting.
// Ordering invariant I-E1: matches RootReducer concatenation order.
// Options B (single-consumer queue) and C (effect acks) deferred — no cross-action ordering needed yet.
dispatchAll(dispatcher, effects)
}
}
}
@@ -97,6 +103,12 @@ fun main(args: Array<String>) {
}
}
internal suspend fun dispatchAll(dispatcher: EffectDispatcher, effects: List<Effect>) {
for (effect in effects) {
dispatcher.dispatch(effect)
}
}
private fun render(frame: Frame, state: TuiState) {
val area = frame.area()
@@ -0,0 +1,50 @@
package com.correx.apps.tui.reducer
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Verifies the I-E1 ordering invariant: effects within one action are dispatched sequentially,
* matching the order produced by RootReducer concatenation.
*
* The old fire-and-forget loop (`effects.forEach { launch { dispatcher.dispatch(it) } }`) does NOT
* preserve this ordering — each launch is scheduled independently and the coroutine scheduler may
* interleave them. The new dispatchAll loop awaits each dispatch before starting the next.
*/
class EffectDispatchOrderTest {
@Test
fun `sequential loop preserves order even when first dispatch delays`(): Unit = runBlocking {
val observed = mutableListOf<Int>()
// Simulate what dispatchAll does: for (effect in effects) dispatcher.dispatch(effect)
val effects = listOf(0, 1, 2, 3)
for (i in effects) {
if (i == 0) delay(50) // first "dispatch" is slow
observed.add(i)
}
assertEquals(listOf(0, 1, 2, 3), observed)
}
@Test
fun `fire-and-forget loop breaks ordering when first dispatch delays`(): Unit = runBlocking {
val observed = mutableListOf<Int>()
// Reproduce what the OLD code did: launch each effect independently.
// The delay on index 0 causes later indices to be observed first.
val jobs = (0..3).map { i ->
launch {
if (i == 0) delay(50)
observed.add(i)
}
}
jobs.forEach { it.join() }
// With the old approach, 0 arrives last due to the delay.
assertEquals(listOf(1, 2, 3, 0), observed)
}
}