Skip to content

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.

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.

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 event
EvExternal (mkEvent_ @'CANCEL) -- payload-less
-- internal (raised by the engine): EvDone, EvInvokeDone, EvInvokeError, EvTimer, EvInit
matches :: forall s. (KnownKey s, HasState spec s) => Machine spec -> Bool
matchesKey :: KeyKind st => st -> Machine spec -> Bool
activeStates :: Machine spec -> [NodeName] -- Text wire names
activeKeys :: KeyKind st => Machine spec -> [st] -- values of your state enum
context :: Machine spec -> Ctx spec
status :: Machine spec -> Status spec -- Running | Finished out
availableEvents :: 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.

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.

Each macrostep returns sEffects :: [EffectReq], in order, cancels before starts:

ReqStartTimer (TimerKey node delayMs docIndex)
ReqCancelTimer (TimerKey node delayMs docIndex)
ReqStartInvoke invokeId serviceName ownerNode
ReqCancelInvoke invokeId

To 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.

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 Bool
sendNamed :: 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 out
  • send returns False once the machine has finished, faulted, or been halted. sendNamed is the dynamic boundary: it decodes a named external event against the chart’s declared event table (Left on an unknown name or bad payload).
  • Timers are real (optDelay defaults to threadDelay) and generation-tagged: a timer that fired concurrently with its own cancellation cannot mis-trigger after the state re-entered.
  • waitFinished blocks (STM) until a terminal state; halt cancels every live timer, invocation, and child actor, and is idempotent.
subscribe :: Interpreter spec -> (Notification spec -> IO ()) -> IO (IO ())
data Notification spec
= NotifyStepped (Stepped spec) -- a macrostep committed (trace + effects + sends)
| NotifyFault StepFault -- terminal
| NotifyHalted -- terminal

subscribe 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.

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.

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.