Skip to content

Commit a6bfd50

Browse files
bedaHovorkaclaude
andcommitted
fix(SP0.11): green-up clean build integrationTest — KMP break, dispatcher wiring, pacing tests
Four SP0.11 (thin ShuntingLoop shell, #733) regressions fixed; none were covered by CI because this branch's workflow run was never approved (goal-10's last green CI is SP0.10). 1. KMP metadata compile break: ShuntingLoop.startAction used JVM-only Thread/runBlocking in commonMain. New expect/actual platformStartDaemonThread (util/PlatformThread.kt): JVM = daemon Thread + runBlocking; native (linuxX64) = CoroutineScope(newSingleThreadContext). Local gemma4 review suggested kotlin.concurrent.thread(isDaemon=true) — rejected: that API is JVM-only, does not exist on Kotlin/Native. 2. Zero-train runs everywhere the async :dispatcher-agent stack isn't wired: SP0.11 removed inline admission/reservation policy but only desktop-ui's ExampleRegistry got the replacement wiring. New sim/SynchronousDispatcherWiring.wireSynchronousDispatcher(env, loop) — production RuleBasedDispatcher + core ports, deciding and applying synchronously on the sim thread each iteration (pre-SP0.11 semantics; deterministic by construction, immune to the PR #740/#742 async races). Used by :fast-sim production (Main, NativeExampleRegistry — fixes 7 native integration tests + JVM/native parity) and by the bare-ShuntingLoop test suites (JvmParity, ShuntingLoopRegression, TrainReporter, Metrics, TrainCoverage, KoinGoldenOutput, SimulationSpeedGolden). Golden baselines pass unchanged — the synchronous wiring reproduces pre-SP0.11 outputs. 3. AgentLoopDriverTest pacing tests updated to the SP0.11 stagnant-snapshot contract runCycle gained (skip decide/throttle on a re-read of the same sim tick, pause still honoured): multi-cycle test now advances simTime per cycle; zero-delta test replaced by stagnantSimTimeSkipsCycle pinning the skip behaviour (no re-decide — that re-decide was the #740 race source). 4. RuleBasedDispatcherDeterminismTest flaked (~1 in 3 suite runs) because the free-running driver thread races the unthrottled headless sim — admission timing drifted with OS scheduling. The Goal 10 A3 gate asserts determinism of the DISPATCHER, so executeRun now runs a lock-step handshake: the sim thread grants the driver exactly one cycle per tick and drains its decisions in the same tick, all on production components. 5 consecutive suite runs green (50/50 repetitions). Verified: ./gradlew clean build integrationTest --no-build-cache (122 tasks, 120 executed) BUILD SUCCESSFUL — includes :core jvm+native tests, :core purity gate, :desktop-ui, :dispatcher-agent, :fast-sim linuxX64 tests, detekt, ktlint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7041d10 commit a6bfd50

16 files changed

Lines changed: 265 additions & 25 deletions

File tree

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrackBlock
2828
import cz.vutbr.fit.interlockSim.util.Util
2929
import cz.vutbr.fit.interlockSim.util.currentTimeMillisKMP
3030
import cz.vutbr.fit.interlockSim.util.platformSleep
31-
import kotlinx.coroutines.runBlocking
31+
import cz.vutbr.fit.interlockSim.util.platformStartDaemonThread
3232
import org.koin.core.component.KoinComponent
3333

3434
/**
@@ -304,11 +304,9 @@ class ShuntingLoop(
304304
// SP0.11: Launch the external agent-driver coroutine alongside the kDisco kernel.
305305
// The action runs in a daemon thread so the JVM can exit cleanly when the
306306
// simulation finishes; the loop inside the action checks [isSimActive].
307+
// KMP: thread creation is JVM-only, hence the expect/actual indirection.
307308
agentDriverAction?.let { action ->
308-
val t = Thread { runBlocking { action() } }
309-
t.isDaemon = true
310-
t.name = "dispatcher-agent-driver"
311-
t.start()
309+
platformStartDaemonThread("dispatcher-agent-driver", action)
312310
}
313311
}
314312

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* Brno University of Technology
2+
* Faculty of Information Technology
3+
*
4+
* BSc Thesis 2006/2007
5+
*
6+
* Railway Interlocking Simulator
7+
*
8+
* Bedrich Hovorka
9+
*/
10+
package cz.vutbr.fit.interlockSim.sim
11+
12+
import cz.vutbr.fit.interlockSim.context.SimulationEnvironment
13+
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkActuatorPort
14+
import cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort
15+
import cz.vutbr.fit.interlockSim.ports.RouteRequestResult
16+
import io.github.oshai.kotlinlogging.KotlinLogging
17+
18+
private val logger = KotlinLogging.logger {}
19+
20+
/**
21+
* Wires a [ShuntingLoop] to a [RuleBasedDispatcher] that decides **and applies
22+
* synchronously on the kDisco sim thread**, once per iteration.
23+
*
24+
* ## Why this exists (SP0.11 follow-up)
25+
*
26+
* The SP0.11 thin-shell refactor (Issue #733) removed all admission and
27+
* route-reservation policy from [ShuntingLoop]. The full asynchronous stack
28+
* (queue + applier + off-kernel driver thread, wired by `:desktop-ui`'s
29+
* `ExampleRegistry.wireDispatcherAgent`) is JVM-only and lives in
30+
* `:dispatcher-agent`. Consumers that cannot or need not run the async stack —
31+
* the `:fast-sim` native CLI and every test that runs a bare `ShuntingLoop` —
32+
* use this synchronous wiring instead; without it a bare loop admits zero trains.
33+
*
34+
* ## Semantics
35+
*
36+
* Identical decision logic to production ([RuleBasedDispatcher]) with the
37+
* pre-SP0.11 timing: each [ShuntingLoop.iteration] publishes fresh observations,
38+
* then the control-step listener decides and applies in the same tick. Because
39+
* observation and application are never decoupled, the duplicate-decision races
40+
* the async driver needs guards for (PR #740, Issue #742) cannot occur here by
41+
* construction, and runs are fully deterministic.
42+
*
43+
* Usage (before `context.run()`):
44+
* ```kotlin
45+
* val loop = ShuntingLoop(context, endTime)
46+
* wireSynchronousDispatcher(context, loop)
47+
* context.setMainProcess(loop)
48+
* context.run()
49+
* ```
50+
*
51+
* @param env The simulation environment backing the ports
52+
* @param loop The loop to wire; its [ShuntingLoop.snapshotCaptureHook] and
53+
* [ShuntingLoop.controlStepListener] are overwritten
54+
* @param maxConcurrentTrains Admission cap, defaults to production's
55+
* [RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS]
56+
* @since Issue #733 / #742 follow-up (SP0.11 green-up)
57+
*/
58+
fun wireSynchronousDispatcher(
59+
env: SimulationEnvironment,
60+
loop: ShuntingLoop,
61+
maxConcurrentTrains: Int = RuleBasedDispatcher.DEFAULT_MAX_CONCURRENT_TRAINS
62+
) {
63+
val perceptionPort =
64+
DefaultNetworkPerceptionPort(
65+
env = env,
66+
activeTrains = loop::getApprovedTrains
67+
)
68+
val actuatorPort = DefaultNetworkActuatorPort(env = env)
69+
val dispatcher = RuleBasedDispatcher(maxConcurrentTrains = maxConcurrentTrains)
70+
71+
loop.snapshotCaptureHook = perceptionPort::captureSnapshot
72+
loop.controlStepListener =
73+
ControlStepListener {
74+
val observation =
75+
DispatchObservation(
76+
snapshot = perceptionPort.snapshot(),
77+
unapprovedTrains = loop.getQueuedTrains(),
78+
innerBlockInputs = loop.getInnerBlockInputs(),
79+
outerBlockInputs = loop.getOuterBlockInputs()
80+
)
81+
dispatcher.decide(observation).forEach { decision ->
82+
applyDecision(decision, loop, actuatorPort)
83+
}
84+
}
85+
}
86+
87+
private fun applyDecision(
88+
decision: DispatchDecision,
89+
loop: ShuntingLoop,
90+
actuatorPort: DefaultNetworkActuatorPort
91+
) {
92+
when (decision) {
93+
is DispatchDecision.ApproveTrain -> loop.approveQueuedTrain(decision.trainId)
94+
is DispatchDecision.ReservePath -> {
95+
val result =
96+
actuatorPort.requestRoute(
97+
decision.trainId,
98+
decision.fromSemaphoreName,
99+
decision.toSeparatorName
100+
)
101+
when (result) {
102+
is RouteRequestResult.Reserved -> loop.incrementBlockTransition(decision.trainId)
103+
else -> {
104+
// Blocked/conflict outcomes are routine "wait and retry next tick" contention.
105+
logger.debug {
106+
"wireSynchronousDispatcher: ReservePath ${decision.fromSemaphoreName}" +
107+
"${decision.toSeparatorName} for ${decision.trainId} not applied: $result"
108+
}
109+
loop.incrementFailedReservation()
110+
}
111+
}
112+
}
113+
is DispatchDecision.NoAction -> Unit
114+
}
115+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package cz.vutbr.fit.interlockSim.util
2+
3+
/**
4+
* Starts a named daemon thread that runs [action] as a blocking coroutine.
5+
*
6+
* KMP expect/actual: the JVM implementation launches a daemon [Thread] wrapping
7+
* `runBlocking { action() }`, so the JVM can exit cleanly when the simulation
8+
* finishes. Used by the SP0.11 dispatcher-agent driver loop
9+
* ([cz.vutbr.fit.interlockSim.sim.ShuntingLoop.agentDriverAction]), which is a
10+
* JVM-only feature — the native target has no agent driver and its actual fails
11+
* fast if ever invoked.
12+
*
13+
* @param name Thread name (for diagnostics, e.g. "dispatcher-agent-driver")
14+
* @param action Suspend action to run for the thread's lifetime
15+
*/
16+
expect fun platformStartDaemonThread(
17+
name: String,
18+
action: suspend () -> Unit
19+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package cz.vutbr.fit.interlockSim.util
2+
3+
import kotlinx.coroutines.runBlocking
4+
5+
actual fun platformStartDaemonThread(
6+
name: String,
7+
action: suspend () -> Unit
8+
) {
9+
val t = Thread { runBlocking { action() } }
10+
t.isDaemon = true
11+
t.name = name
12+
t.start()
13+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ class JvmParityReferenceTest : KoinTestBase() {
6969
stream.use { factory.createContext(it) }
7070
).use { context ->
7171
context.getInOuts()
72-
context.setMainProcess(ShuntingLoop(context, END_TIME))
72+
val loop = ShuntingLoop(context, END_TIME)
73+
wireSynchronousDispatcher(context, loop)
74+
context.setMainProcess(loop)
7375
val reporter = TextReporter(Verbosity.DEFAULT) { output.add(it) }
7476
context.addPropertyChangeListener(reporter)
7577
context.run()

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class ShuntingLoopRegressionTest : KoinTestBase() {
8080
// When: Run shunting loop simulation for 60 time units (enough for 1-2 trains)
8181
logger.info { "Starting ShuntingLoop regression test (60 time units)" }
8282
val shuntingLoop = ShuntingLoop(context, 60L)
83+
wireSynchronousDispatcher(context, shuntingLoop)
8384
context.setMainProcess(shuntingLoop)
8485
context.run()
8586

@@ -128,7 +129,9 @@ class ShuntingLoopRegressionTest : KoinTestBase() {
128129

129130
// When: Run simulation for 30 time units (short test for performance verification)
130131
logger.info { "TEST: Creating ShuntingLoop..." }
131-
context.setMainProcess(ShuntingLoop(context, 30L))
132+
val loop = ShuntingLoop(context, 30L)
133+
wireSynchronousDispatcher(context, loop)
134+
context.setMainProcess(loop)
132135
logger.info { "TEST: ShuntingLoop created, starting simulation..." }
133136
val startWallTime = System.currentTimeMillis()
134137
context.run()
@@ -159,6 +162,7 @@ class ShuntingLoopRegressionTest : KoinTestBase() {
159162

160163
// When: Run simulation for 100 time units (enough for 2-3 trains)
161164
val shuntingLoop = ShuntingLoop(context, 100L)
165+
wireSynchronousDispatcher(context, shuntingLoop)
162166
context.setMainProcess(shuntingLoop)
163167
context.run()
164168

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ class TrainCoverageIntegrationTest : KoinTestBase() {
111111
}
112112
)
113113

114-
ctx.setMainProcess(ShuntingLoop(ctx, 200L))
114+
val loop = ShuntingLoop(ctx, 200L)
115+
wireSynchronousDispatcher(ctx, loop)
116+
ctx.setMainProcess(loop)
115117
ctx.run()
116118

117119
assertThat(endEvents.get(), name = "train end events in extended sim")
@@ -138,7 +140,9 @@ class TrainCoverageIntegrationTest : KoinTestBase() {
138140
}
139141
)
140142

141-
ctx.setMainProcess(ShuntingLoop(ctx, 60L))
143+
val loop = ShuntingLoop(ctx, 60L)
144+
wireSynchronousDispatcher(ctx, loop)
145+
ctx.setMainProcess(loop)
142146
ctx.run()
143147

144148
assertThat(trainApproved.get(), name = "trains approved (exercising Front paths)")
@@ -272,7 +276,9 @@ class TrainCoverageIntegrationTest : KoinTestBase() {
272276
fun lengthCheckerCheckExercisedThroughSimulation() {
273277
val ctx = loadVyhybnaContext()
274278

275-
ctx.setMainProcess(ShuntingLoop(ctx, 30L))
279+
val loop = ShuntingLoop(ctx, 30L)
280+
wireSynchronousDispatcher(ctx, loop)
281+
ctx.setMainProcess(loop)
276282
ctx.run()
277283

278284
assertThat(ctx.getGraph()).isNotNull()

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ class TrainReporterIntegrationTest : KoinTestBase() {
7777
fun trainReporterEnabledPathCoverage() {
7878
// ShuntingLoop.ENABLED_REPORT_TYPES includes TRAIN_CONTINUOUS — no extra setup needed
7979
loadVyhybnaContext().use { ctx ->
80-
ctx.setMainProcess(ShuntingLoop(ctx, 30L))
80+
val loop = ShuntingLoop(ctx, 30L)
81+
wireSynchronousDispatcher(ctx, loop)
82+
ctx.setMainProcess(loop)
8183

8284
val reportCount = countTrainContinuousEvents(ctx)
8385

@@ -102,7 +104,9 @@ class TrainReporterIntegrationTest : KoinTestBase() {
102104
// `ShuntingLoop(ctx, 10L)` runs until simulation time 10 (`endTime`),
103105
// with at most 2 concurrent trains active at once.
104106
loadVyhybnaContext().use { ctx ->
105-
ctx.setMainProcess(ShuntingLoop(ctx, 10L))
107+
val loop = ShuntingLoop(ctx, 10L)
108+
wireSynchronousDispatcher(ctx, loop)
109+
ctx.setMainProcess(loop)
106110

107111
val reportCount = countTrainContinuousEvents(ctx)
108112

@@ -170,7 +174,9 @@ class TrainReporterIntegrationTest : KoinTestBase() {
170174
// - Lower bound: >= 25 proves ~1 Hz cadence (not spurious single event)
171175
// - Upper bound: < 100 (tighter than 300) — would catch hold(0.01) throttle bug
172176
loadVyhybnaContext().use { ctx ->
173-
ctx.setMainProcess(ShuntingLoop(ctx, 30L))
177+
val loop = ShuntingLoop(ctx, 30L)
178+
wireSynchronousDispatcher(ctx, loop)
179+
ctx.setMainProcess(loop)
174180

175181
val reportCount = countTrainContinuousEvents(ctx)
176182

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import cz.vutbr.fit.interlockSim.context.DefaultSimulationContext
1616
import cz.vutbr.fit.interlockSim.context.SimulationContext
1717
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
1818
import cz.vutbr.fit.interlockSim.sim.ShuntingLoop
19+
import cz.vutbr.fit.interlockSim.sim.wireSynchronousDispatcher
1920
import cz.vutbr.fit.interlockSim.testutil.KoinTestBase
2021
import cz.vutbr.fit.interlockSim.testutil.TestFixtures
2122
import cz.vutbr.fit.interlockSim.util.Util
@@ -65,7 +66,9 @@ class MetricsIntegrationTest : KoinTestBase() {
6566
}
6667
// Initialize dynamic wrapper map (mirrors ShuntingLoopSmokeTest).
6768
context.getInOuts()
68-
context.setMainProcess(ShuntingLoop(context, endTime))
69+
val loop = ShuntingLoop(context, endTime)
70+
wireSynchronousDispatcher(context, loop)
71+
context.setMainProcess(loop)
6972
return context
7073
}
7174

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cz.vutbr.fit.interlockSim.util
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.DelicateCoroutinesApi
5+
import kotlinx.coroutines.ExperimentalCoroutinesApi
6+
import kotlinx.coroutines.launch
7+
import kotlinx.coroutines.newSingleThreadContext
8+
9+
/**
10+
* Native (linuxX64) actual: runs [action] on a dedicated background thread via a
11+
* single-threaded coroutine dispatcher named [name].
12+
*
13+
* Daemon semantics match the JVM actual: a Kotlin/Native process exits when `main`
14+
* returns regardless of live worker threads, so the driver loop never blocks
15+
* process shutdown. The dispatcher is intentionally not closed — it lives for the
16+
* driver's lifetime, exactly like the JVM daemon thread.
17+
*/
18+
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
19+
actual fun platformStartDaemonThread(
20+
name: String,
21+
action: suspend () -> Unit
22+
) {
23+
val dispatcher = newSingleThreadContext(name)
24+
CoroutineScope(dispatcher).launch { action() }
25+
}

0 commit comments

Comments
 (0)