class FSM::AsyncService(T)

Overview

The fiber-and-mailbox interpreter.

AsyncService(T) is a second interpreter beside Service(T), not a replacement. It serializes by ownership: exactly one fiber ever executes the machine's code, and other fibers reach it only by putting a message in the mailbox. This is the load-bearing difference from Service, which serializes by lock. The three separable concerns land here as follows: the machine definition is immutable after build and safe to share (sealing); the interpreter's own state advances atomically because only the owning fiber touches it; and nothing is guaranteed about T, which is the caller's responsibility. Service arranges the second concern with a per-instance mutex over interpreter state; AsyncService arranges it by fiber ownership instead.

Planning happens on the owning fiber. This is the purity contract: a guard reads context T while planning, and a guard on the owning fiber never sees a T some other fiber is mutating mid-plan, because the owning fiber is the only one that ever runs the machine's code. Purity is what the library asks of a guard; ownership is what the interpreter supplies.

#post is fire-and-forget and returns immediately; #send blocks the CALLING fiber, not a thread, and reads the reply off a channel. The rule that comes with that: post from inside a callback, send from outside. A send from inside a callback of the same instance deadlocks, because it would be waiting on itself; post from inside a callback remains safe. See #send.

AsyncService exposes no context getter. Reads go through #send on the owning fiber, which is the one place structural enforcement of context isolation is available. That absence is deliberate and is why no such method appears below.

Included Modules

Defined in:

fsm/async_service.cr

Class Method Summary

Instance Method Summary

Class Method Detail

def self.start(machine : Machine(T), initial : String, context : T) : AsyncService(T) #

Start an async service interpreting the machine from an initial state, registering no observers. The name pairs with #stop and the lifecycle predicates and avoids colliding with Crystal's builtin fiber spawn. The owning fiber and mailbox are private; the caller drives the async service only through post, send, and stop.


[View source]
def self.start(machine : Machine(T), initial : String, context : T, & : ObserverRegistrar -> ) : AsyncService(T) #

Start an async service and register observers through a builder block. The block receives an ObserverRegistrar, mirroring Service.interpret: on_transition and on_event_processed handlers are copied once into the async service and the registrar is sealed before start returns.


[View source]

Instance Method Detail

def error : Exception | Nil #

The exception that errored the async service, or nil when it has not errored. This is the exception a callback raised under post, recorded because post has no return value to carry the failure back.


[View source]
def errored? : Bool #

Whether a callback raised under post and poisoned the async service. An errored async service has stopped draining its mailbox.


[View source]
def post(event : String) : Nil #

Enqueue an event to the mailbox and return immediately. Fire-and-forget: the work happens on the owning fiber.

A post from inside a callback is the safe direction: it is detected as running on the owning fiber and queued as a cascade, drained within the current step. A post from any other fiber goes to the mailbox.

A callback that raises under post poisons the async service: post has already returned, so there is no snapshot to hand back and the failure is recorded in the interpreter instead. A post into a stopped or errored async service is dropped silently, because there is no return value to carry a refusal.


[View source]
def running? : Bool #

Whether the owning fiber is still draining the mailbox. A separate concept from State#status, which describes one transaction.


[View source]
def send(event : String) : State #

Enqueue an event and block the CALLING fiber until the owning fiber replies with the State snapshot built for the step. A callback that raises under send yields a Failed snapshot returned as a value; no exception crosses the reply channel.

Send from outside a callback only. A send from inside a callback of the same instance deadlocks: the callback runs on the owning fiber, and the reply it waits on can only be produced by that same owning fiber, so it waits on itself forever. From inside a callback, post instead. A cross-fiber send made while holding a context lock the owning fiber's callback wants is the same deadlock seen through that lock, since the caller blocks on the reply while the callback blocks on the lock.

A send into a stopped or errored async service short-circuits before touching the mailbox and returns a Failed snapshot as a value: the owning fiber is no longer draining, so enqueuing and waiting for a reply would deadlock. An errored async service returns the recorded poisoning exception; a stopped one returns a StoppedError.


[View source]
def stop : Nil #

Stop the async service and return once it has stopped. Synchronous: #stopped? is deterministically true after this returns. The stop request travels through the mailbox behind any events already queued, so those events drain before it stops; nothing queued after the stop is processed. On an async service that has already stopped or errored the mailbox is closed, and stop returns without effect.


[View source]
def stopped? : Bool #

Whether the async service was stopped through #stop.


[View source]