class
FSM::Service(T)
- FSM::Service(T)
- Reference
- Object
Overview
The synchronous interpreter.
Service(T) serializes by lock: many fibers may execute the machine's code, one at a time, queueing on @transition_mutex. It holds the shared immutable machine, the caller's context, and one cached State snapshot.
The serialization contract, as it lands here: @transition_mutex covers interpreter state only, that is @state and the observers read alongside it. The machine definition is safe to share without the lock because it is sealed and immutable after build. T is the caller's responsibility; the library guarantees nothing about a context shared across interpreters or fibers. AsyncService(T) serializes the same interpreter state by fiber ownership instead of a lock: exactly one fiber ever runs the machine's code.
When T carries its own lock, that lock nests inside @transition_mutex, never the reverse: guards and callbacks receive the context directly and only reach its lock while @transition_mutex is already held; an observer reaches it only if a caller's closure captures the context externally, which still runs under the same lock. The order is transition-mutex-before-context-mutex on every internal path. A caller who calls send while holding a context lock the step's callbacks will want inverts that order and can deadlock.
The snapshot is the interpreter's only position field. Service derives the
current StateDefinition from snapshot.id when it needs to plan, rather than
holding both a snapshot and a definition and keeping them in step. #current_state
returns that cached snapshot, and #matches? compares against its id.
Included Modules
Defined in:
fsm/service.crClass Method Summary
-
.interpret(machine : Machine(T), initial_state : String, context : T) : Service(T)
Interpret the machine from an initial state, registering no observers.
-
.interpret(machine : Machine(T), initial_state : String, context : T, & : ObserverRegistrar -> ) : Service(T)
Interpret the machine and register observers through a builder block.
Instance Method Summary
-
#current_state : State
The cached State snapshot.
-
#matches?(state_id : String) : Bool
Sugar for
current_state.id == state_id. -
#send(event : String) : State
Send an event, blocking until it is applied, and return the resulting snapshot.
Class Method Detail
Interpret the machine from an initial state, registering no observers. Validates the initial state against the sealed machine and builds the initial snapshot: the initial id, Success, and no error.
Interpret the machine and register observers through a builder block. The block receives an ObserverRegistrar; it registers on_transition and on_event_processed handlers into it. The handlers are copied once into this service and the registrar is sealed before interpret returns, so no caller can mutate observers on the live interpreter. This mirrors how the Machine and State builders seal at the end of their block.
Instance Method Detail
The cached State snapshot. One type
describes the runtime value everywhere it is read; current_state.id gives the
string when that is what is wanted. State is an immutable value, so returning it
leaks no mutability back into the interpreter.
Locked under @transition_mutex because @state is a 3-field struct snapshot that
#send replaces as a whole: a parallel reader can tear it mid-write. The lock is
what makes the interpreter's own state advance atomically.
A callback of this same service calls on the owner fiber, which already holds the mutex; re-acquiring the checked mutex would raise "Can't lock mutex recursively", so the whole outer transition would fall into the failure envelope. The owner instead reads @state directly, no lock: @state is written only by the owner fiber while it holds the lock, so the owner reading its own writes is safe, and an entry callback reading here sees the OLD, pre-commit snapshot. Non-owner fibers take the locked path unchanged.
Sugar for current_state.id == state_id. Locked for the
same torn-read reason as #current_state, and
takes the same owner-fiber fast path so a reentrant callback does not deadlock
on the checked mutex.
Send an event, blocking until it is applied, and return the resulting snapshot. Guards run inside the critical section.
A send called from inside a callback of this same service is reentrant: it runs on the fiber that already holds @transition_mutex, so it cannot re-acquire the checked mutex. Instead it queues the event and returns the CURRENT committed snapshot, the state the interpreter is committed to at the moment of the call. During a step the commit has not happened yet (it runs after the callbacks), so an entry callback's reentrant send observes the OLD state. The queued event drains after the current step commits, in the outer send's drain loop below.