KStateMachine / kstatemachine

Powerful Kotlin Multiplatform library with clean DSL syntax for creating complex state machines and statecharts driven by Kotlin Coroutines.
https://kstatemachine.github.io/kstatemachine/
Boost Software License 1.0
368 stars 21 forks source link

cancel processEvent may cause StateMachine entry an incorrect state #109

Closed pujunhui closed 1 month ago

pujunhui commented 2 months ago

I have the following code:

class Test(
    private val scope: CoroutineScope
) {
    private val machine: StateMachine = createStateMachineBlocking(
        scope = scope,
        name = "Test"
    ) {
        logger = StateMachine.Logger { println("TAG: ${it()}") }

        val idleState = initialState("IdleState")
        val authState = state("AuthState")
        val workState = dataState<String>("WorkState")

        idleState {
            transition<LoginEvent> {
                targetState = authState
            }
        }

        authState {
            var job: Job? = null
            onEntry {
                job = scope.launch {
                    val token = getToken() //getToken is a time-consuming method
                    this@createStateMachineBlocking.processEvent(ActiveEvent(token))
                }
            }
            onExit {
                job?.cancelAndJoin()
                job = null
            }
            transition<LogoutEvent> {
                targetState = idleState
            }
            dataTransition<ActiveEvent, String> {
                targetState = workState
            }
        }

        workState {
            onEntry {
                println("TAG: workState entry")
            }
        }
    }

    private suspend fun getToken(): String {
        delay(10.seconds)
        return "token123"
    }

    object LogoutEvent : Event
    object LoginEvent : Event
    data class ActiveEvent(val token: String) : DataEvent<String> {
        override val data = token
    }

    suspend fun start() {
        machine.processEvent(LoginEvent)
    }
}

fun main() = runBlocking {
    val test = Test(this)
    test.start()
}

execute result

TAG: StateMachineImpl(Test) started
TAG: StartEventImpl triggers DefaultTransition(start transition) from StateMachineImpl(Test) to [StateMachineImpl(Test)]
TAG: Parent StateMachineImpl(Test) entering child DefaultState(IdleState)
TAG: LoginEvent triggers DefaultTransition from DefaultState(IdleState) to [DefaultState(AuthState)]
TAG: Exiting DefaultState(IdleState)
TAG: Parent StateMachineImpl(Test) entering child DefaultState(AuthState)
TAG: ActiveEvent triggers DefaultTransition from DefaultState(AuthState) to [DefaultDataState(WorkState)]
TAG: Exiting DefaultState(AuthState)

when i get token at authState, transition to workState, but running at authState#onExit,will cancel the job,than cause the processEvent cancel,As a result, the state machine did not enter the workState?

Is this a design flaw or am I using it incorrectly?

nsk90 commented 2 months ago

Hi, the code sample has a deadlock.

1) the first problem is that there are two nested calls to runBlocking() which is wrong (runBlocking and createStateMachineBlocking - uses runBlocking internally) use createStateMachine instead. 2) the second is a deadlock of cancelAndJoin() call. Replacing cancelAndJoin() with cancel() fixes the deadlock and the program completes as expected. The machine behaviour is correct.

At the moment I can't say the reason why cancelAndJoin() deadlocks, I have to dig deeper to understand it.

Here is fixed sample:

package ru.nsk.samples

import kotlinx.coroutines.*
import ru.nsk.kstatemachine.event.DataEvent
import ru.nsk.kstatemachine.event.Event
import ru.nsk.kstatemachine.state.*
import ru.nsk.kstatemachine.statemachine.StateMachine
import ru.nsk.kstatemachine.statemachine.createStateMachine
import kotlin.time.Duration.Companion.seconds

private object LogoutEvent : Event
private object LoginEvent : Event
private data class ActiveEvent(val token: String) : DataEvent<String> {
    override val data = token
}

private suspend fun test(scope: CoroutineScope): StateMachine {
    return createStateMachine(
        scope = scope,
        name = "Test"
    ) machine@{
        logger = StateMachine.Logger { println("TAG: ${it()}") }

        val idleState = initialState("IdleState")
        val authState = state("AuthState")
        val workState = dataState<String>("WorkState")

        idleState {
            transition<LoginEvent> {
                targetState = authState
            }
        }

        authState {
            var job: Job? = null
            onEntry {
                job = scope.launch {
                    val token = getToken() // getToken is a time-consuming method
                    this@machine.processEvent(ActiveEvent(token))
                }
            }
            onExit {
                job?.cancel()
                job = null
            }
            transition<LogoutEvent> {
                targetState = idleState
            }
            dataTransition<ActiveEvent, String> {
                targetState = workState
            }
        }

        workState {
            onEntry {
                println("TAG: workState entry")
            }
        }
    }
}

private suspend fun getToken(): String {
    delay(3.seconds)
    return "token123"
}

fun main(): Unit = runBlocking {
    val machine = test(this)
    machine.processEvent(LoginEvent)
}

and the output

TAG: StateMachineImpl(Test) started
TAG: StartEventImpl triggers DefaultTransition(start transition) from StateMachineImpl(Test) to [StateMachineImpl(Test)]
TAG: Parent StateMachineImpl(Test) entering child DefaultState(IdleState)
TAG: LoginEvent triggers DefaultTransition from DefaultState(IdleState) to [DefaultState(AuthState)]
TAG: Exiting DefaultState(IdleState)
TAG: Parent StateMachineImpl(Test) entering child DefaultState(AuthState)
TAG: ActiveEvent triggers DefaultTransition from DefaultState(AuthState) to [DefaultDataState(WorkState)]
TAG: Exiting DefaultState(AuthState)
TAG: Parent StateMachineImpl(Test) entering child DefaultDataState(WorkState)
TAG: workState entry
nsk90 commented 2 months ago

Ahh the deadlock reason is simple, when we call cancellAndJoin() from onExit() callback we block the machine execution. And processEvent(ActiveEvent(token)) call cannot ever complete. So they are waiting for each other.

You can use either cancel() as in sample above or if you need to do something after the job is joined use cancelAndJoin() but asynchronously:

            onExit {
                scope.launch {
                    job?.cancelAndJoin()
                    job = null
                    println("job has been canceled and joined")
                }
            }