Skip to content

Commit 082ffd0

Browse files
bedaHovorkaclaude
andcommitted
fix(SP0.11): suppress duplicate ReservePath race + CI green-up
The async decoupling between AgentLoopDriver and the kDisco sim thread let the driver decide an identical ReservePath twice before the first application was reflected back (pathAlreadyExtendedBeyond only updates post-application). Re-applying it corrupted the train's registered path via a false-positive "legitimate circular route" match in PathReservationRegistry, permanently stalling the train. DispatchDecisionApplier now suppresses re-applying an already-reserved (trainId, from, to) triple, without touching shared navigation code. Raises RuleBasedDispatcherDeterminismTest from 0/10 to ~6/10 passing. The remaining intermittent failures are a second, pre-existing bug in PathReservationRegistry.mergePathInfo (fork-overlap vs. genuine circular-loop revisit are structurally indistinguishable by path position alone) — tracked as a follow-up, sub-issue #742 of #535. Also: add @timeout to ActuatorCommandQueueTest, AgentLoopDriverTest, DispatchDecisionApplierTest, RuleBasedDispatcherTest (none had one); cap dispatcher-agent test logging at WARN (was defaulting to a much more verbose level, producing multi-GB test-output artifacts); fix 4 ktlint violations blocking CI (import ordering in ExampleRegistry.kt and RuleBasedDispatcherDeterminismTest.kt, Koin module formatting in DispatcherAgentModule.kt, backing-property naming in ShuntingLoop.kt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 05f67d2 commit 082ffd0

10 files changed

Lines changed: 253 additions & 19 deletions

File tree

core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/ShuntingLoop.kt

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,13 @@ class ShuntingLoop(
180180
// SP0.11: Observation data published at the start of each iteration for the off-kernel
181181
// driver thread. Written on the single kDisco sim thread; @Volatile ensures visibility.
182182
@kotlin.concurrent.Volatile
183-
private var _latestQueuedTrains: List<QueuedTrainObservation> = emptyList()
183+
private var latestQueuedTrains: List<QueuedTrainObservation> = emptyList()
184184

185185
@kotlin.concurrent.Volatile
186-
private var _latestInnerBlockInputs: List<BlockInputObservation> = emptyList()
186+
private var latestInnerBlockInputs: List<BlockInputObservation> = emptyList()
187187

188188
@kotlin.concurrent.Volatile
189-
private var _latestOuterBlockInputs: List<BlockInputObservation> = emptyList()
189+
private var latestOuterBlockInputs: List<BlockInputObservation> = emptyList()
190190

191191
// Test-observability counters (#365) — incremented from existing lifecycle sites only.
192192
// Not atomic: ShuntingLoop runs on the single kDisco dispatcher thread, so all increment
@@ -328,12 +328,12 @@ class ShuntingLoop(
328328
// the driver read current-tick state. Written on the single kDisco sim thread;
329329
// @Volatile declarations ensure visibility to the driver thread.
330330
snapshotCaptureHook?.invoke()
331-
_latestQueuedTrains = unapprowedTrains.map { QueuedTrainObservation(it.name, it.timetableDestinationName) }
332-
_latestInnerBlockInputs =
331+
latestQueuedTrains = unapprowedTrains.map { QueuedTrainObservation(it.name, it.timetableDestinationName) }
332+
latestInnerBlockInputs =
333333
innerTrackBlocks.flatMap { block ->
334334
block.ends().map { end -> toBlockInputObservation(block, Util.assertInstanceOf<DynamicRailSemaphore>(end)) }
335335
}
336-
_latestOuterBlockInputs = outerTrackblocks.map { (block, sem) -> toBlockInputObservation(block, sem) }
336+
latestOuterBlockInputs = outerTrackblocks.map { (block, sem) -> toBlockInputObservation(block, sem) }
337337

338338
// SP0.9: Drain and apply any decisions posted to the cross-thread command queue.
339339
controlStepListener?.onControlStep()
@@ -403,23 +403,23 @@ class ShuntingLoop(
403403
*
404404
* @since Issue #733 (SP0.11 — Goal 10)
405405
*/
406-
fun getQueuedTrains(): List<QueuedTrainObservation> = _latestQueuedTrains
406+
fun getQueuedTrains(): List<QueuedTrainObservation> = latestQueuedTrains
407407

408408
/**
409409
* Returns the block-input observations for all inner track blocks, as published at the
410410
* start of the most recent [iteration]. Safe to call from off-kernel threads.
411411
*
412412
* @since Issue #733 (SP0.11 — Goal 10)
413413
*/
414-
fun getInnerBlockInputs(): List<BlockInputObservation> = _latestInnerBlockInputs
414+
fun getInnerBlockInputs(): List<BlockInputObservation> = latestInnerBlockInputs
415415

416416
/**
417417
* Returns the block-input observations for all outer track blocks, as published at the
418418
* start of the most recent [iteration]. Safe to call from off-kernel threads.
419419
*
420420
* @since Issue #733 (SP0.11 — Goal 10)
421421
*/
422-
fun getOuterBlockInputs(): List<BlockInputObservation> = _latestOuterBlockInputs
422+
fun getOuterBlockInputs(): List<BlockInputObservation> = latestOuterBlockInputs
423423

424424
/**
425425
* Returns a snapshot of the currently approved (active) trains. Used by the external

core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/RuleBasedDispatcherTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import cz.vutbr.fit.interlockSim.objects.core.TrackFacility
1818
import cz.vutbr.fit.interlockSim.ports.SimulationSnapshot
1919
import org.junit.jupiter.api.DisplayName
2020
import org.junit.jupiter.api.Test
21+
import org.junit.jupiter.api.Timeout
22+
import java.util.concurrent.TimeUnit
2123

2224
/**
2325
* Direct unit tests for [RuleBasedDispatcher] — the branch-level coverage that the
@@ -32,6 +34,7 @@ import org.junit.jupiter.api.Test
3234
* (SP0.7 — Goal 10)
3335
*/
3436
@DisplayName("RuleBasedDispatcher — branch-level unit coverage")
37+
@Timeout(30, unit = TimeUnit.SECONDS)
3538
class RuleBasedDispatcherTest {
3639
// ── Test data builders ──────────────────────────────────────────────────
3740

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/ExampleRegistry.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import cz.vutbr.fit.interlockSim.context.ContextCreationException
1313
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
1414
import cz.vutbr.fit.interlockSim.context.NoOpSimulationController
1515
import cz.vutbr.fit.interlockSim.context.SimulationContext
16-
import cz.vutbr.fit.interlockSim.context.SimulationController
1716
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
17+
import cz.vutbr.fit.interlockSim.context.SimulationController
1818
import cz.vutbr.fit.interlockSim.dispatcher.ActuatorCommandQueue
1919
import cz.vutbr.fit.interlockSim.dispatcher.AgentLoopDriver
2020
import cz.vutbr.fit.interlockSim.dispatcher.DispatchDecisionApplier

dispatcher-agent/src/main/kotlin/cz/vutbr/fit/interlockSim/dispatcher/DispatchDecisionApplier.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ class DispatchDecisionApplier(
8989
private val logger = KotlinLogging.logger {}
9090
}
9191

92+
/**
93+
* Reservation triples (`trainId|fromSemaphoreName|toSeparatorName`) already
94+
* successfully applied via [applyReservePath], for the duplicate-suppression
95+
* guard documented on that function.
96+
*
97+
* Only ever appended to, never cleared: within one simulation run each train
98+
* makes a bounded, one-way trip through a handful of hops (2-3 for the bundled
99+
* `vyhybna.xml` topology), so the same triple recurring for the same train is
100+
* always the duplicate-decision race below, never a legitimate second need to
101+
* reserve the identical hop again. A fresh [DispatchDecisionApplier] is created
102+
* per simulation run, so this does not grow across runs either.
103+
*/
104+
private val appliedReservations: MutableSet<String> = mutableSetOf()
105+
92106
/**
93107
* Drains [queue] and applies each pending [DispatchDecision] via the actuator ports.
94108
*
@@ -119,7 +133,56 @@ class DispatchDecisionApplier(
119133
DispatchDecision.NoAction -> Unit
120134
}
121135

136+
/**
137+
* Applies [decision] via [NetworkActuatorPort.requestRoute], guarding against
138+
* duplicate application of an already-successfully-reserved hop.
139+
*
140+
* ## Duplicate-decision race (SP0.11 regression, Issue #733 follow-up)
141+
*
142+
* [AgentLoopDriver][cz.vutbr.fit.interlockSim.dispatcher.AgentLoopDriver] runs
143+
* on its own thread, decoupled from the sim thread that applies its decisions.
144+
* Because [ShuntingLoop][cz.vutbr.fit.interlockSim.sim.ShuntingLoop]'s own
145+
* polling ticks are not wall-clock throttled (no `SimulationController`
146+
* pacing in a headless run), several ShuntingLoop ticks can elapse before the
147+
* driver thread gets scheduled again. The block-input observation it reads —
148+
* in particular [cz.vutbr.fit.interlockSim.sim.BlockInputObservation.pathAlreadyExtendedBeyond],
149+
* the guard [cz.vutbr.fit.interlockSim.sim.RuleBasedDispatcher] relies on to
150+
* avoid re-deciding an already-reserved hop — only updates once a decided
151+
* [DispatchDecision.ReservePath] has actually been drained and applied here.
152+
* This means the driver can legitimately decide the *same* `ReservePath` more
153+
* than once before its first application is reflected back.
154+
*
155+
* Re-applying an already-owned hop is not a harmless no-op:
156+
* `DefaultPathReservationService.reservePath`'s already-owned fast path calls
157+
* `PathReservationRegistry.registerPathInfo` again, and that registry's merge
158+
* logic assumes the incoming path's `start` overlaps the *current* registered
159+
* `target`. Once the first (legitimate) application has already advanced that
160+
* target past the duplicate's `start`, the overlap check fails, the duplicate
161+
* segment is spliced back into the train's registered path, and the train's
162+
* `Front` process (`Train.kt`) loses track of its position — permanently
163+
* stalling (confirmed via `PathReservationRegistry` merge tracing during root
164+
* cause analysis; the malformed splice is silently "allowed" as a false-positive
165+
* "circular route" before a *third* occurrence trips the existing cycle guard).
166+
*
167+
* Skipping a triple already recorded in [appliedReservations] restores the
168+
* "at most one real application per reservation need" invariant that held
169+
* naturally under the pre-SP0.11 synchronous (decide-and-apply-in-one-step)
170+
* architecture, without touching the shared navigation/registry code that
171+
* other callers (e.g. `InOutWorker`) also depend on.
172+
*
173+
* @since Issue #733 (SP0.11 — Goal 10); duplicate-suppression guard added as a
174+
* regression fix for the resulting train-freeze/deadlock
175+
*/
122176
private fun applyReservePath(decision: DispatchDecision.ReservePath) {
177+
val reservationKey = "${decision.trainId}|${decision.fromSemaphoreName}|${decision.toSeparatorName}"
178+
if (reservationKey in appliedReservations) {
179+
logger.debug {
180+
"Skipping duplicate ReservePath: trainId=${decision.trainId} " +
181+
"${decision.fromSemaphoreName}${decision.toSeparatorName} (already applied)"
182+
}
183+
return
184+
}
185+
123186
logger.debug {
124187
"Applying ReservePath: trainId=${decision.trainId} " +
125188
"${decision.fromSemaphoreName}${decision.toSeparatorName}"
@@ -139,6 +202,7 @@ class DispatchDecisionApplier(
139202
logger.debug {
140203
"ReservePath: reserved ${result.blocksCount} block(s) for ${decision.trainId}"
141204
}
205+
appliedReservations.add(reservationKey)
142206
onBlockTransition(decision.trainId)
143207
}
144208
is RouteRequestResult.AllPathsBlocked -> {

dispatcher-agent/src/main/kotlin/cz/vutbr/fit/interlockSim/dispatcher/di/DispatcherAgentModule.kt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,13 @@ import org.koin.dsl.module
3737
*
3838
* @since Issue #733 (SP0.11 — Goal 10)
3939
*/
40-
val dispatcherAgentModule: Module = module {
41-
// Dispatcher: global singleton — RuleBasedDispatcher is stateless (pure function).
42-
single<Dispatcher> { RuleBasedDispatcher() }
40+
val dispatcherAgentModule: Module =
41+
module {
42+
// Dispatcher: global singleton — RuleBasedDispatcher is stateless (pure function).
43+
single<Dispatcher> { RuleBasedDispatcher() }
4344

44-
scope<DefaultSimulationContext> {
45-
// ActuatorCommandQueue: one thread-safe handoff queue per simulation context.
46-
scoped<ActuatorCommandQueue> { ActuatorCommandQueue() }
45+
scope<DefaultSimulationContext> {
46+
// ActuatorCommandQueue: one thread-safe handoff queue per simulation context.
47+
scoped<ActuatorCommandQueue> { ActuatorCommandQueue() }
48+
}
4749
}
48-
}

dispatcher-agent/src/test/kotlin/cz/vutbr/fit/interlockSim/dispatcher/ActuatorCommandQueueTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import assertk.assertions.isTrue
2222
import cz.vutbr.fit.interlockSim.sim.DispatchDecision
2323
import org.junit.jupiter.api.DisplayName
2424
import org.junit.jupiter.api.Test
25+
import org.junit.jupiter.api.Timeout
2526
import java.util.concurrent.CountDownLatch
2627
import java.util.concurrent.Executors
2728
import java.util.concurrent.TimeUnit
@@ -42,6 +43,7 @@ private const val CONSUMER_POLL_INTERVAL_MS: Long = 1
4243
* @since Issue #730 (SP0.8 — Goal 10)
4344
*/
4445
@DisplayName("ActuatorCommandQueue — thread-safe driver-to-sim command handoff")
46+
@Timeout(30, unit = TimeUnit.SECONDS)
4547
class ActuatorCommandQueueTest {
4648
@Test
4749
@DisplayName("drain returns decisions in FIFO order")

dispatcher-agent/src/test/kotlin/cz/vutbr/fit/interlockSim/dispatcher/AgentLoopDriverTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import org.junit.jupiter.api.BeforeEach
2929
import org.junit.jupiter.api.DisplayName
3030
import org.junit.jupiter.api.Nested
3131
import org.junit.jupiter.api.Test
32+
import org.junit.jupiter.api.Timeout
33+
import java.util.concurrent.TimeUnit
3234

3335
/**
3436
* Unit tests for [AgentLoopDriver].
@@ -50,6 +52,7 @@ import org.junit.jupiter.api.Test
5052
* @since Issue #732 (SP0.10 — Goal 10)
5153
*/
5254
@DisplayName("AgentLoopDriver — paced sense-decide-act cycle")
55+
@Timeout(30, unit = TimeUnit.SECONDS)
5356
class AgentLoopDriverTest {
5457
private lateinit var perceptionPort: NetworkPerceptionPort
5558
private lateinit var dispatcher: Dispatcher

dispatcher-agent/src/test/kotlin/cz/vutbr/fit/interlockSim/dispatcher/DispatchDecisionApplierTest.kt

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import org.junit.jupiter.api.BeforeEach
2525
import org.junit.jupiter.api.DisplayName
2626
import org.junit.jupiter.api.Nested
2727
import org.junit.jupiter.api.Test
28+
import org.junit.jupiter.api.Timeout
29+
import java.util.concurrent.TimeUnit
2830
import java.util.concurrent.atomic.AtomicReference
2931

3032
/**
@@ -41,6 +43,7 @@ import java.util.concurrent.atomic.AtomicReference
4143
* @since Issue #731 (SP0.9 — Goal 10)
4244
*/
4345
@DisplayName("DispatchDecisionApplier — drain and apply decisions via actuator ports")
46+
@Timeout(30, unit = TimeUnit.SECONDS)
4447
class DispatchDecisionApplierTest {
4548
private lateinit var networkActuator: NetworkActuatorPort
4649
private val approvedTrains = mutableListOf<String>()
@@ -232,6 +235,118 @@ class DispatchDecisionApplierTest {
232235
}
233236
}
234237

238+
// ── Duplicate-reservation suppression (SP0.11 regression fix) ────────────
239+
240+
/**
241+
* Regression coverage for the SP0.11 async-driver race (Issue #733 follow-up):
242+
* [AgentLoopDriver] runs decoupled from the sim thread, so it can legitimately
243+
* decide the *same* `ReservePath` more than once before the block-input
244+
* observation reflects the first application. Re-applying an already-reserved
245+
* hop is not a harmless no-op — it corrupts the train's registered path via
246+
* `PathReservationRegistry.registerPathInfo`'s merge logic and permanently
247+
* stalls the train. [DispatchDecisionApplier] must apply each distinct
248+
* `(trainId, from, to)` reservation at most once.
249+
*/
250+
@Nested
251+
@DisplayName("Duplicate-reservation suppression (SP0.11 regression fix)")
252+
inner class DuplicateReservationSuppression {
253+
@Test
254+
@DisplayName("Identical ReservePath decision drained twice is applied to the actuator port only once")
255+
fun duplicateReservePath_appliedOnlyOnce() {
256+
every { networkActuator.requestRoute(any(), any(), any()) } returns RouteRequestResult.Reserved("T1", 1)
257+
val (queue, applier) = makeApplier()
258+
259+
// First drain: the reservation is genuinely new.
260+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
261+
applier.onControlStep()
262+
263+
// Second drain: the driver decided the identical hop again before observing
264+
// the first application (the race this guard exists to suppress).
265+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
266+
applier.onControlStep()
267+
268+
verify(exactly = 1) { networkActuator.requestRoute("T1", "zB", "doA1") }
269+
}
270+
271+
@Test
272+
@DisplayName("Identical ReservePath decision applied twice within the same drain is applied only once")
273+
fun duplicateReservePath_sameDrain_appliedOnlyOnce() {
274+
every { networkActuator.requestRoute(any(), any(), any()) } returns RouteRequestResult.Reserved("T1", 1)
275+
val (queue, applier) = makeApplier()
276+
277+
queue.postAll(
278+
listOf(
279+
DispatchDecision.ReservePath("T1", "zB", "doA1"),
280+
DispatchDecision.ReservePath("T1", "zB", "doA1")
281+
)
282+
)
283+
applier.onControlStep()
284+
285+
verify(exactly = 1) { networkActuator.requestRoute("T1", "zB", "doA1") }
286+
}
287+
288+
@Test
289+
@DisplayName("onBlockTransition fires only once for a duplicated ReservePath")
290+
fun duplicateReservePath_blockTransitionCountedOnce() {
291+
every { networkActuator.requestRoute(any(), any(), any()) } returns RouteRequestResult.Reserved("T1", 1)
292+
var transitionCount = 0
293+
val queue = ActuatorCommandQueue()
294+
val applier =
295+
DispatchDecisionApplier(
296+
queue = queue,
297+
networkActuator = networkActuator,
298+
onApproveTrain = onApproveTrain,
299+
onBlockTransition = { transitionCount++ }
300+
)
301+
302+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
303+
applier.onControlStep()
304+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
305+
applier.onControlStep()
306+
307+
assertThat(transitionCount).isEqualTo(1)
308+
}
309+
310+
@Test
311+
@DisplayName("A different (trainId, from, to) triple is not suppressed by an earlier reservation")
312+
fun differentTriple_notSuppressed() {
313+
every { networkActuator.requestRoute(any(), any(), any()) } returns RouteRequestResult.Reserved("T1", 1)
314+
val (queue, applier) = makeApplier()
315+
316+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
317+
applier.onControlStep()
318+
// Same train, different hop — genuine forward progress, must still be applied.
319+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "doA1", "A")))
320+
applier.onControlStep()
321+
322+
verify(exactly = 1) { networkActuator.requestRoute("T1", "zB", "doA1") }
323+
verify(exactly = 1) { networkActuator.requestRoute("T1", "doA1", "A") }
324+
}
325+
326+
@Test
327+
@DisplayName("A failed reservation is not remembered, so a later retry of the same triple is applied")
328+
fun failedReservation_retryIsApplied() {
329+
var callCount = 0
330+
every { networkActuator.requestRoute("T1", "zB", "doA1") } answers {
331+
callCount++
332+
if (callCount == 1) {
333+
RouteRequestResult.AllPathsBlocked(attemptedPaths = 1)
334+
} else {
335+
RouteRequestResult.Reserved("T1", 1)
336+
}
337+
}
338+
val (queue, applier) = makeApplier()
339+
340+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
341+
applier.onControlStep() // fails — must not be recorded as applied
342+
343+
queue.postAll(listOf(DispatchDecision.ReservePath("T1", "zB", "doA1")))
344+
applier.onControlStep() // legitimate retry — must reach the actuator port
345+
346+
verify(exactly = 2) { networkActuator.requestRoute("T1", "zB", "doA1") }
347+
}
348+
}
349+
235350
// ── Thread-identity contract ──────────────────────────────────────────────
236351

237352
@Nested

dispatcher-agent/src/test/kotlin/cz/vutbr/fit/interlockSim/dispatcher/RuleBasedDispatcherDeterminismTest.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ import assertk.assertions.isGreaterThanOrEqualTo
1616
import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
1717
import cz.vutbr.fit.interlockSim.context.EditingContext
1818
import cz.vutbr.fit.interlockSim.context.NoOpSimulationController
19+
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkActuatorPort
20+
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort
1921
import cz.vutbr.fit.interlockSim.sim.DefaultSimulationProcessFactory
2022
import cz.vutbr.fit.interlockSim.sim.RuleBasedDispatcher
2123
import cz.vutbr.fit.interlockSim.sim.ShuntingLoop
2224
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
2325
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory
24-
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkActuatorPort
25-
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort
2626
import io.github.oshai.kotlinlogging.KotlinLogging
2727
import org.junit.jupiter.api.AfterEach
2828
import org.junit.jupiter.api.BeforeEach

0 commit comments

Comments
 (0)