Skip to content

Commit f648bc4

Browse files
CopilotbedaHovorka
andcommitted
fix: eliminate shared latches map and restore startedLatch.await() in nullMainProcessNoOp (issue #495)
Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/57c03945-0a5c-4b57-a850-c1a29f81f146 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent 78f97ed commit f648bc4

1 file changed

Lines changed: 54 additions & 39 deletions

File tree

desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationControllerSpeedPropagationTest.kt

Lines changed: 54 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import cz.vutbr.fit.interlockSim.sim.LoopProcess
2222
import cz.vutbr.fit.interlockSim.sim.SpeedControllable
2323
import io.mockk.every
2424
import io.mockk.mockk
25+
import io.mockk.unmockkAll
26+
import org.junit.jupiter.api.AfterEach
27+
import org.junit.jupiter.api.BeforeEach
2528
import org.junit.jupiter.api.DisplayName
2629
import org.junit.jupiter.api.Test
2730
import org.junit.jupiter.api.Timeout
@@ -33,6 +36,16 @@ import java.util.concurrent.TimeUnit
3336
* `SimulationController.setSpeed` must reach the running main process when it
3437
* implements [SpeedControllable], not just the bookkeeping field on
3538
* [SimulationRunner]. Without this, speed buttons are dead in `exampleGui` mode.
39+
*
40+
* ## Synchronisation design
41+
* Each test gets its own [startedLatch] and [blockSim] latches (instance fields,
42+
* not companion-object shared state). [blockSim] is counted down in [tearDown]
43+
* even if the test fails early, so the sim thread always unblocks and daemon threads
44+
* do not linger across tests.
45+
*
46+
* The 30-second timeouts accommodate busy CI runners where thread scheduling can be
47+
* delayed by several seconds (CI uses `maxParallelForks = availableProcessors()`
48+
* with multiple JVM forks competing for CPU).
3649
*/
3750
@DisplayName("SimulationController -> SpeedControllable propagation")
3851
class SimulationControllerSpeedPropagationTest {
@@ -51,44 +64,59 @@ class SimulationControllerSpeedPropagationTest {
5164
override suspend fun iteration() = Unit
5265
}
5366

67+
// ── Per-test latches (instance fields, NOT companion-object shared state) ───
68+
// JUnit 5 creates a fresh test instance per method, so these are naturally
69+
// isolated. tearDown() guarantees blockSim is released even on test failure,
70+
// preventing lingering sim threads from other tests.
71+
private lateinit var startedLatch: CountDownLatch
72+
private lateinit var blockSim: CountDownLatch
73+
74+
@BeforeEach
75+
fun setUp() {
76+
startedLatch = CountDownLatch(1)
77+
blockSim = CountDownLatch(1)
78+
}
79+
80+
@AfterEach
81+
fun tearDown() {
82+
// Guarantee the sim thread unblocks even if the test fails before releaseSim().
83+
blockSim.countDown()
84+
unmockkAll()
85+
}
86+
5487
private fun newController(): SimulationController = SimulationController()
5588

56-
private fun mockContext(mainProcess: LoopProcess?): DefaultSimulationContext {
57-
val started = CountDownLatch(1)
58-
val blockSim = CountDownLatch(1)
59-
return mockk<DefaultSimulationContext>(relaxed = true).also { ctx ->
89+
private fun mockContext(mainProcess: LoopProcess?): DefaultSimulationContext =
90+
mockk<DefaultSimulationContext>(relaxed = true).also { ctx ->
6091
every { ctx.getMainProcess() } returns mainProcess
6192
every { ctx.run() } answers {
62-
started.countDown()
63-
blockSim.await(10, TimeUnit.SECONDS)
93+
startedLatch.countDown()
94+
blockSim.await(30, TimeUnit.SECONDS)
6495
}
65-
// Stash the latches on the mock for the test to access.
66-
ctx.attachLatches(started, blockSim)
6796
}
68-
}
6997

7098
@Test
71-
@Timeout(value = 10, unit = TimeUnit.SECONDS)
99+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
72100
@DisplayName("setSpeed propagates to a SpeedControllable main process while running")
73101
fun setSpeedReachesSpeedControllable() {
74102
val process = FakeSpeedyMainProcess()
75103
val ctx = mockContext(process)
76104
val controller = newController()
77105

78106
controller.start(ctx)
79-
assertThat(ctx.startedLatch().await(5, TimeUnit.SECONDS)).isTrue()
107+
assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue()
80108

81109
controller.setSpeed(2.5)
82110

83111
assertThat(process.speedMultiplier).isEqualTo(2.5)
84112
assertThat(controller.runner!!.speedMultiplier).isEqualTo(2.5)
85113

86-
ctx.releaseSim()
114+
blockSim.countDown()
87115
controller.stop()
88116
}
89117

90118
@Test
91-
@Timeout(value = 10, unit = TimeUnit.SECONDS)
119+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
92120
@DisplayName("setSpeed before start applies on next start (desiredSpeed propagation)")
93121
fun preStartSpeedAppliedOnStart() {
94122
val process = FakeSpeedyMainProcess()
@@ -97,63 +125,50 @@ class SimulationControllerSpeedPropagationTest {
97125

98126
controller.setSpeed(0.5)
99127
controller.start(ctx)
100-
assertThat(ctx.startedLatch().await(5, TimeUnit.SECONDS)).isTrue()
128+
assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue()
101129

102130
assertThat(process.speedMultiplier).isEqualTo(0.5)
103131
assertThat(controller.runner!!.speedMultiplier).isEqualTo(0.5)
104132

105-
ctx.releaseSim()
133+
blockSim.countDown()
106134
controller.stop()
107135
}
108136

109137
@Test
110-
@Timeout(value = 10, unit = TimeUnit.SECONDS)
138+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
111139
@DisplayName("setSpeed is a safe no-op when main process is not SpeedControllable")
112140
fun nonControllableMainProcessNoOp() {
113141
val ctx = mockContext(PlainMainProcess())
114142
val controller = newController()
115143

116144
controller.start(ctx)
117-
assertThat(ctx.startedLatch().await(5, TimeUnit.SECONDS)).isTrue()
145+
assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue()
118146

119147
controller.setSpeed(3.0) // must not throw, must not crash on the cast
120148
assertThat(controller.runner!!.speedMultiplier).isEqualTo(3.0)
121149

122-
ctx.releaseSim()
150+
blockSim.countDown()
123151
controller.stop()
124152
}
125153

126154
@Test
127-
@Timeout(value = 10, unit = TimeUnit.SECONDS)
155+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
128156
@DisplayName("setSpeed is a safe no-op when main process is null")
129157
fun nullMainProcessNoOp() {
130158
val ctx = mockContext(null)
131159
val controller = newController()
132160

133161
controller.start(ctx)
134-
// runner and speedMultiplier are assigned synchronously inside start() before
135-
// the sim thread is launched, so no need to await the sim thread here.
162+
// Wait for the sim thread to actually start and block on blockSim.await()
163+
// before we call setSpeed() and assert on runner.speedMultiplier.
164+
// On a busy CI runner, thread scheduling can delay the sim thread by
165+
// several seconds — without this await the test is non-deterministic.
166+
assertThat(startedLatch.await(30, TimeUnit.SECONDS)).isTrue()
167+
136168
controller.setSpeed(1.5)
137169
assertThat(controller.runner!!.speedMultiplier).isEqualTo(1.5)
138170

139-
ctx.releaseSim()
171+
blockSim.countDown()
140172
controller.stop()
141173
}
142-
143-
// ── latch wiring kept off the production type ────────────────────────────
144-
// Stores per-test latches via a side-table keyed on the mock instance, so
145-
// production DefaultSimulationContext stays unchanged.
146-
companion object {
147-
private val latches = java.util.IdentityHashMap<DefaultSimulationContext, Pair<CountDownLatch, CountDownLatch>>()
148-
}
149-
150-
private fun DefaultSimulationContext.attachLatches(started: CountDownLatch, block: CountDownLatch) {
151-
latches[this] = started to block
152-
}
153-
154-
private fun DefaultSimulationContext.startedLatch(): CountDownLatch = latches.getValue(this).first
155-
156-
private fun DefaultSimulationContext.releaseSim() {
157-
latches.getValue(this).second.countDown()
158-
}
159174
}

0 commit comments

Comments
 (0)