Running a machine
The semantics are the SCXML / W3C statechart algorithm, implemented as a
pure function. Timers and invoked services never happen inside the step —
they surface as effect requests the caller executes. The same semantics therefore drive the IO runtime and the deterministic
simulator.
The pure step
Section titled “The pure step”initialize :: Monad m => ChartImpl m spec -> Ctx spec -> m (Either StepFault (Stepped spec))step :: Monad m => ChartImpl m spec -> Machine spec -> StepEvent spec -> m (Either StepFault (Stepped spec))initialize enters the initial configuration (running root and initial-state
entry actions, arming their timers/invocations). step processes one event as
a complete macrostep: it selects transitions, runs exit → transition →
entry actions, records history, raises internal events, and runs eventless
(Always) transitions and the raised-event queue to quiescence.
Both return Either StepFault (Stepped spec):
data Stepped spec = Stepped { sMachine :: Machine spec -- the new machine , sEffects :: [EffectReq] -- timers/invocations to (un)arm, in order , sSends :: [SendReq spec] -- cross-actor sends the actions requested , sTrace :: [MicroTrace] -- one entry per microstep — the debug record }A StepFault is a genuinely dynamic failure — EventlessLoop (an
unguarded Always cycle, reported instead of hanging) or an InternalFault.
There is no “unknown state” or “missing handler” fault: those were made
impossible at compile time.
Events
Section titled “Events”step takes a StepEvent. The one you construct is EvExternal; the rest are
events generated by the algorithm (and what your guards/actions observe):
EvExternal (mkEvent @'FETCH url) -- an external, typed eventEvExternal (mkEvent_ @'CANCEL) -- payload-less-- internal (raised by the engine): EvDone, EvInvokeDone, EvInvokeError, EvTimer, EvInitQuerying the machine
Section titled “Querying the machine”matches :: forall s. (KnownKey s, HasState spec s) => Machine spec -> BoolmatchesKey :: KeyKind st => st -> Machine spec -> BoolactiveStates :: Machine spec -> [NodeName] -- Text wire namesactiveKeys :: KeyKind st => Machine spec -> [st] -- values of your state enumcontext :: Machine spec -> Ctx specstatus :: Machine spec -> Status spec -- Running | Finished outavailableEvents :: ChartImpl m spec -> Machine spec -> [Text]availableEventKeys :: KeyKind ev => ChartImpl m spec -> Machine spec -> [ev]matches @'Loading is compile-checked — it does not compile if the chart has
no 'Loading state (and a constructor from the wrong role there is a kind
error). matchesKey Loading takes the state as an ordinary value instead:
state values are valid by construction. activeStates returns the configuration as Text — the constructors’
spellings, the same names snapshots and traces use — while activeKeys
reifies it back to values of your state enum, so a case over it is
exhaustiveness-checked. availableEvents lists the named triggers active
in the current configuration; availableEventKeys gives them as
event values. A Machine can only be produced by initialize, step, or
restore — which is why an illegal configuration cannot
exist behind the API.
Driving it by hand
Section titled “Driving it by hand”run :: ChartImpl IO Fetch -> [StepEvent Fetch] -> IO ()run impl events = do Right s0 <- initialize impl initialCtx final <- foldM stepOne (sMachine s0) events print (status final) where stepOne m ev = do Right stepped <- step impl m ev pure (sMachine stepped)Direct stepping leaves sEffects to the caller: arm timers, run services, and
feed their lifecycle events back into step. The interpreter and simulator
execute those same requests for you.
The effect-request model
Section titled “The effect-request model”Each macrostep returns sEffects :: [EffectReq], in order, cancels before
starts:
ReqStartTimer (TimerKey node delayMs docIndex)ReqCancelTimer (TimerKey node delayMs docIndex)ReqStartInvoke invokeId serviceName ownerNodeReqCancelInvoke invokeIdTo make a delay fire, feed back EvTimer key with the exact key from a
ReqStartTimer; to resolve an invocation, feed EvInvokeDone invokeId value.
The Texts here are wire names — the invoke and service keys’ constructor
spellings (keyNameOf @'GetUser == "GetUser"). A timer’s identity is
(node, delay, document-index), so two After 100s on one state stay
distinct, and a stale timer whose state has since exited is simply dropped by
transition selection.
The IO interpreter
Section titled “The IO interpreter”StateMachine.Interpret executes the effect requests for real. One driver
thread owns the machine; you interact through a handle.
interpret :: EventCodec spec => ChartImpl IO spec -> Ctx spec -> IO (Either StepFault (Interpreter spec))
send :: Interpreter spec -> EventVal spec -> IO BoolsendNamed :: Interpreter spec -> Text -> Value -> IO (Either String Bool)machineView :: Interpreter spec -> IO (Machine spec)waitFinished:: Interpreter spec -> IO (Either StepFault (Output spec))halt :: Interpreter spec -> IO ()main :: IO ()main = do Right sm <- interpret impl initialCtx _ <- send sm (mkEvent @'FETCH someUrl) -- ...the driver thread processes actions; timers fire independently... out <- waitFinished sm -- blocks until Finished / fault / halt print outsendreturnsFalseonce the machine has finished, faulted, or been halted.sendNamedis the dynamic boundary: it decodes a named external event against the chart’s declared event table (Lefton an unknown name or bad payload).- Timers are real (
optDelaydefaults tothreadDelay) and generation-tagged: a timer that fired concurrently with its own cancellation cannot mis-trigger after the state re-entered. waitFinishedblocks (STM) until a terminal state;haltcancels every live timer, invocation, and child actor, and is idempotent.
Observing steps
Section titled “Observing steps”subscribe :: Interpreter spec -> (Notification spec -> IO ()) -> IO (IO ())data Notification spec = NotifyStepped (Stepped spec) -- a macrostep committed (trace + effects + sends) | NotifyFault StepFault -- terminal | NotifyHalted -- terminalsubscribe returns an unsubscribe action. Callbacks run on the driver thread,
keep them cheap and non-blocking; push to a queue rather than calling halt
from inside the callback.
Actors and child charts
Section titled “Actors and child charts”An Invoke of a mkServiceChart spawns the child on its own interpreter,
wired through the invocation’s typed ChildBridge:
the child’s sendParent events are translated to parent events by
bridgeToParent, the parent reaches the child with sendChild "invokeId"
events (the id is the invoke key’s spelling) translated by bridgeToChild,
and the child’s typed Output becomes
the invocation’s onDone payload (recovered with invokeOutput @(Output child)).
Cancelling the invoke (exiting the owner state) halts the child.
Configuring the runtime
Section titled “Configuring the runtime”interpretWith :: EventCodec spec => InterpretOptions spec -> ChartImpl IO spec -> Ctx spec -> IO (Either StepFault (Interpreter spec))
data InterpretOptions spec = InterpretOptions { optDelay :: Int -> IO () -- how to wait out `After` (default threadDelay) , optSendParent :: Maybe (EventVal spec -> IO ()) -- typed ToParent sink (wired for children) }Inject optDelay to make timer scenarios deterministic in an integration test
(a gate you release on demand) instead of sleeping.
For pure, no-thread testing of the same semantics — including timer races — prefer the simulator.