Dear trio comunity,
what is the suggested way to implement a simple state machine with trio?
The intention is to avoid global state variable and additional state transition loop, something like this:
import trio
async def state1_broken():
print('this is state1')
await trio.sleep(0.01)
await state2_broken()
async def state2_broken():
print('this is state2')
await trio.sleep(0.01)
await state1_broken()
trio.run(state1_broken)
This naive implementation obviously does not work, since python does not optimize tail recursive calls. It quickly raises RecursionError: maximum recursion depth exceeded exception.
There are some state machine python libraries, but I am wandering if there is an effective way to solve it within trio.
regards,
Zoran
Answering my own question… Not sure if this is the correct way to go, but introducing the runner solves the problem:
from typing import *
import trio
State: TypeAlias = Callable[[], Awaitable['State']]
async def state1() -> State:
print('this is state1')
await trio.sleep(0.01)
return state2
async def state2() -> State:
print('this is state2')
await trio.sleep(0.01)
return state1
async def runner(initial: State) -> Never:
f = initial
while True:
f = await f()
trio.run(runner, state1)
… or even more simple which also allows passing arguments between states
from typing import *
import trio
State: TypeAlias = Awaitable['State']
async def state1() -> State:
print('this is state1')
await trio.sleep(0)
return state2('test')
async def state2(arg: str) -> State:
print('this is state2')
await trio.sleep(0)
return state1()
async def runner(initial: State) -> Never:
f = initial
while True:
f = await f
trio.run(runner, state1())
You solution is exactly what I was going to suggest when I read your first post. Your “runner()” function, which calls a function f then puts the result into f to run on the next loop iteration, is often called a “trampoline” and it’s the standard trick to work around the lack of a tail call feature in a language. There’s a Wikipedia article for trampoline but it’s weirdly terrible
Your first version of the solution is a bit more Trionic e.g. notice how nursery.start_soon takes a coroutine function (like state1) rather than a coroutine (like state1()). You could still allow arguments to be passed by returning a tuple of (fn, args) (again that matches nursery.start_soon()), although that would be hard to make type hints for. Alternatively, you could use your same strategy but just use functools.partial whenever you want to pass a parameter (e.g., return partial(state2, "test") or return partial(state2, arg="test").