redesign of the state machine
With the current implementation, we run into a deadlock with the lock from the state machine interfering with the accessLock on the module. We can not wait for the state machine to finish while having the accessLock locked by write_target. As a consequence, when restarting the state machine we should not wait, but remember the state function to call and postpone the restart after the cleanup has finished. For this, we want to know the status before calling the state function. - create HasState mixin, using doPoll for driving the machine - StatusCode decorator for assigning a status to a state function - remove the state machines 'threaded' option - 'Retry' is now a unique value instead of a class. The retry period is determined by the (fast) poll interval. - return 'Finish' instead of None for finishing the machine. returning None for state function is now an error, as this might happen easily inadvertently. Change-Id: Icb31367442f10e98be69af3e05a84f12ce5cc966
This commit is contained in:
@ -21,18 +21,30 @@
|
||||
# *****************************************************************************
|
||||
|
||||
|
||||
from frappy.lib.statemachine import StateMachine, Stop, Retry
|
||||
from frappy.core import Drivable, Parameter
|
||||
from frappy.datatypes import StatusType, Enum
|
||||
from frappy.states import StateMachine, Stop, Retry, Finish, Start, HasStates, StatusCode
|
||||
|
||||
|
||||
class LoggerStub:
|
||||
def info(self, fmt, *args):
|
||||
print(fmt % args)
|
||||
|
||||
def debug(self, fmt, *args):
|
||||
pass
|
||||
|
||||
warning = exception = error = info
|
||||
handlers = []
|
||||
|
||||
|
||||
def rise(state):
|
||||
state.step += 1
|
||||
print('rise', state.step)
|
||||
if state.init:
|
||||
state.status = 'rise'
|
||||
state.level += 1
|
||||
if state.level > 3:
|
||||
return turn
|
||||
return Retry()
|
||||
return Retry
|
||||
|
||||
|
||||
def turn(state):
|
||||
@ -42,7 +54,7 @@ def turn(state):
|
||||
state.direction += 1
|
||||
if state.direction > 3:
|
||||
return fall
|
||||
return Retry()
|
||||
return Retry
|
||||
|
||||
|
||||
def fall(state):
|
||||
@ -52,32 +64,35 @@ def fall(state):
|
||||
state.level -= 1
|
||||
if state.level < 0:
|
||||
raise ValueError('crash')
|
||||
return Retry(0) # retry until crash!
|
||||
return fall # retry until crash!
|
||||
|
||||
|
||||
def error_handler(state):
|
||||
state.last_error_name = type(state.last_error).__name__
|
||||
def finish(state):
|
||||
return None
|
||||
|
||||
|
||||
class LoggerStub:
|
||||
def debug(self, fmt, *args):
|
||||
print(fmt % args)
|
||||
info = warning = exception = error = debug
|
||||
handlers = []
|
||||
class Result:
|
||||
cleanup_reason = None
|
||||
|
||||
def __init__(self):
|
||||
self.states = []
|
||||
|
||||
class DummyThread:
|
||||
def is_alive(self):
|
||||
return True
|
||||
def on_error(self, sm):
|
||||
self.cleanup_reason = sm.cleanup_reason
|
||||
|
||||
def on_transition(self, sm, newstate):
|
||||
self.states.append(newstate)
|
||||
|
||||
|
||||
def test_fun():
|
||||
s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub())
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
assert s.step == 0
|
||||
assert s.status == ''
|
||||
s.cycle() # do nothing
|
||||
assert s.step == 0
|
||||
s.start(rise, level=0, direction=0)
|
||||
s.start(rise, cleanup=obj.on_error, level=0, direction=0)
|
||||
s.cycle()
|
||||
for i in range(1, 4):
|
||||
assert s.status == 'rise'
|
||||
assert s.step == i
|
||||
@ -91,56 +106,221 @@ def test_fun():
|
||||
assert s.direction == i - 4
|
||||
s.cycle()
|
||||
s.cycle() # -> crash
|
||||
assert isinstance(s.last_error, ValueError)
|
||||
assert str(s.last_error) == 'crash'
|
||||
assert s.state is None
|
||||
assert isinstance(obj.cleanup_reason, ValueError)
|
||||
assert str(obj.cleanup_reason) == 'crash'
|
||||
assert obj.states == [rise, turn, fall, fall, fall, fall, fall, None]
|
||||
assert s.statefunc is None
|
||||
|
||||
|
||||
def test_max_chain():
|
||||
s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub())
|
||||
s.start(fall, level=999+1, direction=0)
|
||||
assert isinstance(s.last_error, RuntimeError)
|
||||
assert s.state is None
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
s.start(fall, cleanup=obj.on_error, level=999+1, direction=0)
|
||||
s.cycle()
|
||||
assert isinstance(obj.cleanup_reason, RuntimeError)
|
||||
assert s.statefunc is None
|
||||
|
||||
|
||||
def test_stop():
|
||||
s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub())
|
||||
s.start(rise, level=0, direction=0)
|
||||
for _ in range(1, 3):
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
s.start(rise, cleanup=obj.on_error, level=0, direction=0)
|
||||
for _ in range(3):
|
||||
s.cycle()
|
||||
s.stop()
|
||||
s.cycle()
|
||||
assert s.last_error is Stop
|
||||
assert s.state is None
|
||||
assert isinstance(obj.cleanup_reason, Stop)
|
||||
assert obj.states == [rise, None]
|
||||
assert s.statefunc is None
|
||||
|
||||
|
||||
def test_std_error_handling():
|
||||
s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub())
|
||||
s.start(rise, level=0, direction=0)
|
||||
def test_error_handling():
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
s.start(rise, cleanup=obj.on_error, level=0, direction=0)
|
||||
s.cycle()
|
||||
s.level = None # -> TypeError on next step
|
||||
s.cycle()
|
||||
assert s.state is None # default error handler: stop machine
|
||||
assert isinstance(s.last_error, TypeError)
|
||||
assert not hasattr(s, 'last_error_name')
|
||||
|
||||
|
||||
def test_default_error_handling():
|
||||
s = StateMachine(step=0, status='', cleanup=error_handler, threaded=False, logger=LoggerStub())
|
||||
s.start(rise, level=0, direction=0)
|
||||
s.cycle()
|
||||
s.level = None
|
||||
s.cycle()
|
||||
assert s.state is None
|
||||
assert s.last_error_name == 'TypeError'
|
||||
assert isinstance(s.last_error, TypeError)
|
||||
assert isinstance(obj.cleanup_reason, TypeError)
|
||||
assert obj.states == [rise, None]
|
||||
assert s.statefunc is None
|
||||
|
||||
|
||||
def test_cleanup_on_restart():
|
||||
s = StateMachine(step=0, status='', threaded=False, logger=LoggerStub())
|
||||
s.start(rise, level=0, direction=0)
|
||||
def test_on_restart():
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
s.start(rise, cleanup=obj.on_error, level=0, direction=0)
|
||||
s.cycle()
|
||||
s.cycle()
|
||||
s.start(turn)
|
||||
s.cycle()
|
||||
assert s.state is turn
|
||||
assert s.last_error is None
|
||||
assert isinstance(obj.cleanup_reason, Start)
|
||||
obj.cleanup_reason = None
|
||||
s.cycle()
|
||||
assert s.statefunc is turn
|
||||
assert obj.cleanup_reason is None
|
||||
assert obj.states == [rise, None, turn]
|
||||
|
||||
|
||||
def test_finish():
|
||||
obj = Result()
|
||||
s = StateMachine(step=0, status='', transition=obj.on_transition, logger=LoggerStub())
|
||||
s.start(finish, cleanup=obj.on_error, level=0, direction=0)
|
||||
s.cycle()
|
||||
s.cycle()
|
||||
assert obj.states == [finish, None]
|
||||
assert s.statefunc is None
|
||||
|
||||
|
||||
Status = Enum(
|
||||
Drivable.Status,
|
||||
PREPARED=150,
|
||||
PREPARING=340,
|
||||
RAMPING=370,
|
||||
STABILIZING=380,
|
||||
FINALIZING=390,
|
||||
)
|
||||
|
||||
|
||||
class DispatcherStub:
|
||||
# the first update from the poller comes a very short time after the
|
||||
# initial value from the timestamp. However, in the test below
|
||||
# the second update happens after the updates dict is cleared
|
||||
# -> we have to inhibit the 'omit unchanged update' feature
|
||||
omit_unchanged_within = 0
|
||||
|
||||
def __init__(self, updates):
|
||||
self.updates = updates
|
||||
|
||||
def announce_update(self, modulename, pname, pobj):
|
||||
assert modulename == 'obj'
|
||||
if pobj.readerror:
|
||||
self.updates.append((pname, pobj.readerror))
|
||||
else:
|
||||
self.updates.append((pname, pobj.value))
|
||||
|
||||
|
||||
class ServerStub:
|
||||
def __init__(self, updates):
|
||||
self.dispatcher = DispatcherStub(updates)
|
||||
|
||||
|
||||
class Mod(HasStates, Drivable):
|
||||
status = Parameter(datatype=StatusType(Status))
|
||||
_my_time = 0
|
||||
|
||||
def artificial_time(self):
|
||||
return self._my_time
|
||||
|
||||
def on_cleanup(self, sm):
|
||||
return self.cleanup_one
|
||||
|
||||
def state_transition(self, sm, newstate):
|
||||
self.statelist.append(getattr(newstate, '__name__', None))
|
||||
super().state_transition(sm, newstate)
|
||||
|
||||
def state_one(self, sm):
|
||||
if sm.init:
|
||||
return Retry
|
||||
return self.state_two
|
||||
|
||||
@StatusCode('PREPARING', 'state 2')
|
||||
def state_two(self, sm):
|
||||
return self.state_three
|
||||
|
||||
@StatusCode('FINALIZING')
|
||||
def state_three(self, sm):
|
||||
if sm.init:
|
||||
return Retry
|
||||
return self.final_status('IDLE', 'finished')
|
||||
|
||||
@StatusCode('BUSY')
|
||||
def cleanup_one(self, sm):
|
||||
if sm.init:
|
||||
return Retry
|
||||
print('one 2')
|
||||
return self.cleanup_two
|
||||
|
||||
def cleanup_two(self, sm):
|
||||
if sm.init:
|
||||
return Retry
|
||||
return Finish
|
||||
|
||||
def doPoll(self):
|
||||
super().doPoll()
|
||||
self._my_time += 1
|
||||
|
||||
|
||||
def create_module():
|
||||
updates = []
|
||||
obj = Mod('obj', LoggerStub(), {'.description': ''}, ServerStub(updates))
|
||||
obj.initModule()
|
||||
obj.statelist = []
|
||||
try:
|
||||
obj._Module__pollThread(obj.polledModules, None)
|
||||
except TypeError:
|
||||
pass # None is not callable
|
||||
updates.clear()
|
||||
return obj, updates
|
||||
|
||||
|
||||
def test_updates():
|
||||
obj, updates = create_module()
|
||||
obj.start_machine(obj.state_one)
|
||||
for _ in range(10):
|
||||
obj.doPoll()
|
||||
assert updates == [
|
||||
('status', (Status.BUSY, 'state one')), # default: BUSY, function name without '_'
|
||||
('status', (Status.PREPARING, 'state 2')), # explicitly given
|
||||
('status', (Status.FINALIZING, 'state three')), # only code given
|
||||
('status', (Status.IDLE, 'finished')),
|
||||
]
|
||||
|
||||
|
||||
def test_stop_without_cleanup():
|
||||
obj, updates = create_module()
|
||||
obj.start_machine(obj.state_one)
|
||||
obj.doPoll()
|
||||
obj.stop_machine()
|
||||
for _ in range(10):
|
||||
obj.doPoll()
|
||||
assert updates == [
|
||||
('status', (Status.BUSY, 'state one')),
|
||||
('status', (Status.BUSY, 'stopping')),
|
||||
('status', (Status.IDLE, 'stopped')),
|
||||
]
|
||||
assert obj.statelist == ['state_one', None]
|
||||
|
||||
|
||||
def test_stop_with_cleanup():
|
||||
obj, updates = create_module()
|
||||
obj.start_machine(obj.state_one, cleanup=obj.on_cleanup)
|
||||
obj.doPoll()
|
||||
obj.stop_machine()
|
||||
for _ in range(10):
|
||||
obj.doPoll()
|
||||
assert updates == [
|
||||
('status', (Status.BUSY, 'state one')),
|
||||
('status', (Status.BUSY, 'stopping')),
|
||||
('status', (Status.BUSY, 'stopping (cleanup one)')),
|
||||
('status', (Status.IDLE, 'stopped')),
|
||||
]
|
||||
assert obj.statelist == ['state_one', 'cleanup_one', 'cleanup_two', None]
|
||||
|
||||
|
||||
def test_all_restart():
|
||||
obj, updates = create_module()
|
||||
obj.start_machine(obj.state_one, cleanup=obj.on_cleanup, statelist=[])
|
||||
obj.doPoll()
|
||||
obj.start_machine(obj.state_three)
|
||||
for _ in range(10):
|
||||
obj.doPoll()
|
||||
assert updates == [
|
||||
('status', (Status.BUSY, 'state one')),
|
||||
('status', (Status.FINALIZING, 'restarting')),
|
||||
('status', (Status.FINALIZING, 'restarting (cleanup one)')),
|
||||
('status', (Status.FINALIZING, 'state three')),
|
||||
('status', (Status.IDLE, 'finished')),
|
||||
]
|
||||
assert obj.statelist == ['state_one', 'cleanup_one', 'cleanup_two', None, 'state_three', None]
|
||||
|
Reference in New Issue
Block a user