Skip to content

Commit 9fa46d0

Browse files
committed
Allow calling processEvent() from notification callbacks
Such calls are recorded by machine and processed when current processing completes. Do not trigger onStop() when stop() is called multiple times (machine is already stopped). Fix bug that allowed to call processEvent() without exception from notification callback that was triggered from start() method.
1 parent 9f59558 commit 9fa46d0

File tree

7 files changed

+227
-77
lines changed

7 files changed

+227
-77
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package ru.nsk.kstatemachine
2+
3+
import kotlin.collections.ArrayDeque
4+
5+
/**
6+
* Returns [StateMachine.PendingEventHandler] implementation that throws exception. This is an old default behaviour.
7+
*/
8+
fun StateMachine.throwingPendingEventHandler() = StateMachine.PendingEventHandler { pendingEvent, _ ->
9+
error(
10+
"$this can not process pending $pendingEvent as event processing is already running. " +
11+
"Do not call processEvent() from notification listeners or use queuePendingEventHandler()"
12+
)
13+
}
14+
15+
fun StateMachine.queuePendingEventHandler(): StateMachine.PendingEventHandler = QueuePendingEventHandler(this)
16+
17+
internal class QueuePendingEventHandler(private val machine: StateMachine): StateMachine.PendingEventHandler {
18+
private val queue = ArrayDeque<EventAndArgument<*>>()
19+
20+
fun checkEmpty() = check(queue.isEmpty()) { "Event queue is not empty, internal error" }
21+
22+
override fun onPendingEvent(pendingEvent: Event, argument: Any?) {
23+
machine.log { "$machine queued event $pendingEvent with argument $argument " }
24+
queue.add(EventAndArgument(pendingEvent, argument))
25+
}
26+
27+
fun nextEventAndArgument() = queue.removeFirstOrNull()
28+
29+
fun clear() = queue.clear()
30+
}

kstatemachine/src/main/kotlin/ru/nsk/kstatemachine/StateMachineImpl.kt

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,18 @@ abstract class InternalStateMachine(name: String?, childMode: ChildMode) : State
1313

1414
internal class StateMachineImpl(name: String?, childMode: ChildMode, override val autoDestroyOnStatesReuse: Boolean) :
1515
InternalStateMachine(name, childMode) {
16-
/** Access to this field must be thread safe. */
1716
private val _machineListeners = mutableSetOf<StateMachine.Listener>()
1817
override val machineListeners: Collection<StateMachine.Listener> get() = _machineListeners
1918
override var logger: StateMachine.Logger = NullLogger
2019
override var ignoredEventHandler = StateMachine.IgnoredEventHandler { _, _ -> }
21-
override var pendingEventHandler = StateMachine.PendingEventHandler { pendingEvent, _ ->
22-
error(
23-
"$this can not process pending $pendingEvent as event processing is already running. " +
24-
"Do not call processEvent() from notification listeners."
25-
)
26-
}
20+
override var pendingEventHandler = queuePendingEventHandler()
2721
override var listenerExceptionHandler = StateMachine.ListenerExceptionHandler { throw it }
2822
private var _isDestroyed: Boolean = false
2923
override val isDestroyed get() = _isDestroyed
3024

3125
/**
32-
* Help to check that [processEvent] is not called from state machine notification method.
33-
* Access to this field must be thread safe.
26+
* Flag for event processing mechanism, which takes place in [processEvent] and during [start]/[startFrom].
27+
* It is not possible to process new event while previous processing is incomplete.
3428
*/
3529
private var isProcessingEvent = false
3630

@@ -61,25 +55,30 @@ internal class StateMachineImpl(name: String?, childMode: ChildMode, override va
6155
accept(CheckUniqueNamesVisitor())
6256
checkBeforeRunMachine()
6357

64-
runCheckingExceptions {
65-
runMachine(makeStartTransitionParams(this, argument = argument))
66-
recursiveEnterInitialStates(argument)
58+
eventProcessingScope {
59+
runCheckingExceptions {
60+
runMachine(makeStartTransitionParams(this, argument = argument))
61+
recursiveEnterInitialStates(argument)
62+
}
6763
}
6864
}
6965

7066
override fun startFrom(state: IState, argument: Any?) {
7167
checkBeforeRunMachine()
7268

73-
runCheckingExceptions {
74-
val transitionParams = makeStartTransitionParams(this, state, argument)
75-
runMachine(transitionParams)
76-
switchToTargetState(state as InternalState, this, transitionParams)
69+
eventProcessingScope {
70+
runCheckingExceptions {
71+
val transitionParams = makeStartTransitionParams(this, state, argument)
72+
runMachine(transitionParams)
73+
switchToTargetState(state as InternalState, this, transitionParams)
74+
}
7775
}
7876
}
7977

8078
private fun checkBeforeRunMachine() {
8179
check(!isDestroyed) { "$this is already destroyed" }
8280
check(!isRunning) { "$this is already started" }
81+
check(!isProcessingEvent) { "$this is already processing event, this is internal error, please report a bug" }
8382
if (childMode == ChildMode.EXCLUSIVE)
8483
checkNotNull(initialState) { "Initial state is not set, call setInitialState() first" }
8584
}
@@ -93,6 +92,7 @@ internal class StateMachineImpl(name: String?, childMode: ChildMode, override va
9392

9493
override fun stop() {
9594
check(!isDestroyed) { "$this is already destroyed" }
95+
if (!_isRunning) return
9696

9797
runCheckingExceptions {
9898
_isRunning = false
@@ -109,26 +109,60 @@ internal class StateMachineImpl(name: String?, childMode: ChildMode, override va
109109
if (isProcessingEvent) {
110110
pendingEventHandler.onPendingEvent(event, argument)
111111
// pending event cannot be processed while previous event is still processing
112-
// even if PendingEventHandler does not throw
112+
// even if PendingEventHandler does not throw. QueuePendingEventHandler implementation stores such events
113+
// to be processed later.
113114
return
114115
}
115116

117+
eventProcessingScope {
118+
process(EventAndArgument(event, argument))
119+
}
120+
}
121+
122+
private fun process(eventAndArgument: EventAndArgument<*>) {
116123
var eventProcessed: Boolean? = null
124+
125+
runCheckingExceptions {
126+
eventProcessed = doProcessEvent(eventAndArgument)
127+
}
128+
129+
if (eventProcessed == false) {
130+
log { "$this ignored ${eventAndArgument.event::class.simpleName}" }
131+
ignoredEventHandler.onIgnoredEvent(eventAndArgument.event, eventAndArgument.argument)
132+
}
133+
}
134+
135+
/**
136+
* Runs block of code that processes event, and processes all pending events from queue after it if
137+
* [QueuePendingEventHandler] is used.
138+
*/
139+
private fun eventProcessingScope(block: () -> Unit) {
140+
val queue = pendingEventHandler as? QueuePendingEventHandler
141+
queue?.checkEmpty()
142+
117143
isProcessingEvent = true
118144
try {
119-
runCheckingExceptions {
120-
eventProcessed = doProcessEvent(EventAndArgument(event, argument))
121-
}
145+
block()
122146

123-
if (eventProcessed == false) {
124-
log { "$this ignored ${event::class.simpleName}" }
125-
ignoredEventHandler.onIgnoredEvent(event, argument)
147+
queue?.let {
148+
var eventAndArgument = it.nextEventAndArgument()
149+
while (eventAndArgument != null) {
150+
process(eventAndArgument)
151+
152+
eventAndArgument = it.nextEventAndArgument()
153+
}
126154
}
155+
} catch (e: Exception) {
156+
queue?.clear()
157+
throw e
127158
} finally {
128159
isProcessingEvent = false
129160
}
130161
}
131162

163+
/**
164+
* Runs block of code that triggers notification listeners
165+
*/
132166
private fun runCheckingExceptions(block: () -> Unit) {
133167
try {
134168
block()

kstatemachine/src/test/kotlin/ru/nsk/kstatemachine/GuardedTransitionTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ class GuardedTransitionTest : StringSpec({
2020
transition<SwitchEvent> {
2121
guard = {
2222
[email protected] { "$event $argument" }
23-
value == "value2" }
23+
value == "value2"
24+
}
2425
targetState = second
2526
callbacks.listen(this)
2627
}

kstatemachine/src/test/kotlin/ru/nsk/kstatemachine/ListenerExceptionHandlerTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class ListenerExceptionHandlerTest : StringSpec({
8383
}
8484

8585
shouldThrow<TestException> { machine.stop() }
86+
machine.stop() // does nothing
8687
machine.isDestroyed shouldBe false
8788
}
8889

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package ru.nsk.kstatemachine
2+
3+
import io.kotest.assertions.throwables.shouldThrow
4+
import io.kotest.core.spec.style.StringSpec
5+
import io.kotest.matchers.shouldBe
6+
import io.mockk.verifySequence
7+
8+
class PendingEventHandlerTest : StringSpec({
9+
"queue event in QueuePendingEventHandler" {
10+
val machine = createStateMachine {
11+
logger = StateMachine.Logger { println(it) }
12+
13+
val third = state("third")
14+
val second = state("second") {
15+
transition<SecondEvent>(targetState = third)
16+
}
17+
initialState("first") {
18+
transition<FirstEvent> {
19+
targetState = second
20+
onTriggered { [email protected](SecondEvent) }
21+
}
22+
}
23+
}
24+
25+
machine.processEvent(FirstEvent)
26+
}
27+
28+
"queue event on machine start" {
29+
val callbacks = mockkCallbacks()
30+
createStateMachine {
31+
logger = StateMachine.Logger { println(it) }
32+
33+
val second = state("second")
34+
35+
initialState("first") {
36+
onEntry { machine.processEvent(SwitchEvent) }
37+
initialState("first internal")
38+
39+
transition<SwitchEvent> {
40+
targetState = second
41+
callbacks.listen(this)
42+
}
43+
}
44+
}
45+
46+
verifySequence { callbacks.onTriggeredTransition(SwitchEvent) }
47+
}
48+
49+
"pending event queue is cleared on processing error" {
50+
val machine = createStateMachine(start = false) {
51+
logger = StateMachine.Logger { println(it) }
52+
53+
val second = state("second") {
54+
transition<SecondEvent>()
55+
}
56+
57+
initialState("first") {
58+
onEntry {
59+
machine.processEvent(FirstEvent)
60+
machine.processEvent(FirstEvent)
61+
machine.processEvent(FirstEvent)
62+
}
63+
initialState("first internal")
64+
65+
transition<FirstEvent>(targetState = second)
66+
}
67+
ignoredEventHandler = StateMachine.IgnoredEventHandler { _, _ ->
68+
throw TestException("test")
69+
}
70+
}
71+
72+
shouldThrow<TestException> { machine.start() }
73+
74+
machine.processEvent(SecondEvent)
75+
}
76+
77+
"throwing PendingEventHandler does not destroy machine" {
78+
val machine = createStateMachine {
79+
logger = StateMachine.Logger { println(it) }
80+
81+
val second = state("second")
82+
initialState("first") {
83+
transition<SwitchEvent> {
84+
targetState = second
85+
onTriggered {
86+
shouldThrow<TestException> { [email protected](SwitchEvent) }
87+
}
88+
}
89+
}
90+
91+
pendingEventHandler = StateMachine.PendingEventHandler { _, _ ->
92+
testError("Already processing")
93+
}
94+
}
95+
96+
machine.processEvent(SwitchEvent)
97+
machine.isDestroyed shouldBe false
98+
}
99+
})

kstatemachine/src/test/kotlin/ru/nsk/kstatemachine/StateMachineTest.kt

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -167,29 +167,6 @@ class StateMachineTest : StringSpec({
167167
shouldThrowUnit<IllegalStateException> { machine.setInitialState(first) }
168168
}
169169

170-
"throwing PendingEventHandler does not destroy machine" {
171-
val machine = createStateMachine {
172-
logger = StateMachine.Logger { println(it) }
173-
174-
val second = state("second")
175-
initialState("first") {
176-
transition<SwitchEvent> {
177-
targetState = second
178-
onTriggered {
179-
shouldThrow<TestException> { [email protected](SwitchEvent) }
180-
}
181-
}
182-
}
183-
184-
pendingEventHandler = StateMachine.PendingEventHandler { _, _ ->
185-
testError("Already processing")
186-
}
187-
}
188-
189-
machine.processEvent(SwitchEvent)
190-
machine.isDestroyed shouldBe false
191-
}
192-
193170
"process event before started" {
194171
createStateMachine {
195172
initialState("first")
@@ -273,36 +250,6 @@ class StateMachineTest : StringSpec({
273250
}
274251
}
275252

276-
"startFrom()" {
277-
val callbacks = mockkCallbacks()
278-
279-
lateinit var state2: State
280-
lateinit var state22: State
281-
282-
val machine = createStateMachine(start = false) {
283-
callbacks.listen(this)
284-
285-
initialState("state1") { callbacks.listen(this) }
286-
state2 = state("state2") {
287-
callbacks.listen(this)
288-
289-
initialState("state2_1") { callbacks.listen(this) }
290-
state22 = state("state2_2") { callbacks.listen(this) }
291-
}
292-
293-
onStarted { callbacks.onStarted(this) }
294-
}
295-
296-
machine.startFrom(state22)
297-
298-
verifySequence {
299-
callbacks.onStarted(machine)
300-
callbacks.onEntryState(machine)
301-
callbacks.onEntryState(state2)
302-
callbacks.onEntryState(state22)
303-
}
304-
}
305-
306253
"restart machine" {
307254
val callbacks = mockkCallbacks()
308255

@@ -329,6 +276,7 @@ class StateMachineTest : StringSpec({
329276
}
330277

331278
machine.stop()
279+
machine.stop() // does nothing
332280
verifySequenceAndClear(callbacks) { callbacks.onStopped(machine) }
333281

334282
machine.start()

0 commit comments

Comments
 (0)