FSL.Machine (fsl v0.2.0)

Copy Markdown View Source

The Finite State Language: describes a process as a finite state machine, à la ExUnit.

A machine is a plain Elixir module — often saved as an .exs file so it can be loaded at run time — that does use FSL.Machine. Here is one going fishing:

defmodule Fishing.Trip do
  use FSL.Machine

  config label: "Bob the angler", quota: 2

  state initial_state do
    appdata_set(:caught, [])
    goto casting
  end

  state casting do
    IO.puts("A cast. The float settles.")
    goto waiting
  end

  state waiting do
    on_events do
      {:bite, fish} ->
        goto striking, "a bite"

      # A duck is worth noticing and worth nothing else. `stay` consumes
      # the event without running the state again — and without re-arming
      # the ten seconds below. Ducks eat your afternoon.
      {:duck, name} ->
        IO.puts("(#{name} paddles past. You wait.)")
        stay "a duck"

      # A detour that comes back by itself.
      {:snag, thing} ->
        goto untangling, "a snag"
    after
      10_000 -> goto packing_up, "patience ran out"
    end
  end

  state untangling do
    IO.puts("You free the line.")
    goto back      # wherever we came from — one slot, not a stack
  end

  state packing_up do
    scenario_success("#{length(appdata_get(:caught))} fish")
  end
end

mix run samples/fishing.exs runs the whole trip — hands as a sub-FSM, a strike window, and the afternoon printed as a Mermaid diagram. It is the file to read next, and it is commented for that.

What a machine can do comes from the embedding

use FSL.Machine gives the language and nothing else: states, transitions, waiting, sub-FSMs. What a state may act on — a socket, a dialog, a media plane — is an FSL.Host away, and a protocol binding usually wraps both in a facade of its own. SIP.Scenario, in Elixip, is use FSL.Machine, host: SIP.FSL.Host, ctx_var: :sip_ctx plus three session mixins, so a SIP scenario writes send_INVITE where the trip above writes IO.puts. The language cannot tell the difference, and that is the property the whole design is built to keep.

Options

  • :host — the FSL.Host implementation the language calls back into for everything it must not know. Defaults to FSL.Host.Default, which answers the least a machine needs.
  • :ctx_var — what this application's machines call the context variable. Defaults to :fsl_ctx; the SIP embedding uses :sip_ctx.
  • :kind:scenario (the default) or :sbb, a service building block.

Entry points

  • MyMachine.run(true) — bootstrap the host, then run one instance.
  • MyMachine.run(false) — run one instance, assuming the host is up.
  • FSL.Runner.run_instance/2 — run one instance in the calling process.

How a state compiles

Each state name do ... end becomes a function __state_name/1 taking the implicit context. Its body must end with a transition macro — goto, scenario_success or scenario_failure — which returns a transition descriptor consumed by FSL.Runner. See that module for the loop.

goto next moves to the next declared state, goto loop re-enters the current one, goto back returns to the state entered before this one, and goto some_state jumps to a named state. Before transitioning, goto checks lasterr: any value other than :ok aborts the machine as a failure.

Inside an on_events clause, stay consumes the event and keeps waiting on the same on_events without re-running the state body.

Summary

Functions

Declare the parameters of the machine.

Turn an after timeout into an absolute deadline, computed once when an on_events is entered.

Transition to another state. target may be a state name, next (the next declared state), loop (re-enter the current state) or back (return to the state the FSM was in before entering this one). desc is an optional short description of the triggering event, used for logging and shown in the monitor. type optionally categorizes that event (:sip, :media, :timer, :http, :db, …) — recorded by the monitor to drive the future sequence diagram, mirroring the command typing of the application's own verbs.

Send an application message to a named child sub-FSM. The child receives it as {:parent_msg, payload}. Unknown name → logged and ignored.

Send an application message to the parent FSM. The parent receives it as {:child_msg, <our name>, payload} — the name the parent assigned with as:, so it matches a stable literal in every state. No-op when this scenario has no parent (so the same scenario also runs standalone).

Like Elixir's receive, but each clause records the type of the matched event so the trailing goto is automatically categorized (no need to pass the type explicitly). The type is inferred from the clause pattern: {:ms_event, …}:media, any other tuple its host recognises ({100, …}, {:BYE, …}) → :sip.

Declare an optional handler run when a cooperative shutdown is requested ({:scenario_ctl, :shutdown, _} received inside an on_events). Compiles to the reserved :__shutdown__ state; its body must end with a transition macro (scenario_aborted/1 recommended). When omitted, the runner terminates the scenario with the :aborted outcome by default.

Teach a machine the namespace of a service building block it is about to call, for the whole module rather than from one state on.

What is left of a deadline from deadline/1, floored at zero — the timeout a stay re-enters its wait with.

Read a key from this block's private sandbox — appdata is shared with the host (that is the point), but a block's scratch space is its own, so it cannot collide with a host key of the same name.

Write a key into this block's private sandbox. Anything the block wants to hand over goes in the event it returns, or under a documented key of the shared appdata — not here.

Enter a service building block: the current process runs module's FSM until it hands control back, then execution continues on the next line of this state body. Not a spawn — no second process, no second set of legs. The block sees this scenario's context, dialogs and mailbox, because it is this process (design docs/design/DESIGN-SBB.md).

End this service building block, posting event to the process and handing control back to the state that called it. The host matches event in its own on_events, like any other event — and behind anything this block left unconsumed, since it goes to the back of the mailbox.

Terminate the scenario as aborted — a controller-driven wind-down (e.g. a cooperative shutdown), distinct from a failure so it is not counted as one. Typically used as the last statement of an on_shutdown block.

Terminate the scenario as a failure, storing reason in the context.

Terminate the scenario successfully, transitioning to the success state.

Spawn another scenario as a sub finite-state machine (a separate process, required because each FSM owns its own mailbox). Hands the child our PID and a local name so the two can exchange messages with notify/2 / notify_parent/1.

Declare a state of the finite state machine. The body must end with a transition macro (goto / scenario_success / scenario_failure).

Consume the matched event and keep waiting on the same on_events, without re-entering the state: the state body is not re-executed, so its side effects (sending a request, arming a timer, allocating media) are not replayed. This is what goto loop cannot do.

Deprecated spelling of spawn_fsm/2, kept so scenarios written before 1.5.0 keep loading. Same semantics, including the path resolution.

Functions

config(opts)

(macro)

Declare the parameters of the machine.

What a key means is the application's business: FSL.Host.build_context/1 turns this list into the context, so what a key means is the application's to decide. FSL.Host.Default puts every key in appdata; the SIP embedding routes some to fields of its own context struct and some to the application environment.

deadline(ms)

@spec deadline(timeout()) :: integer() | :infinity

Turn an after timeout into an absolute deadline, computed once when an on_events is entered.

This pair is what makes stay safe. The timeout of a wait is the deadline of the wait, not of each event: a stay comes back with remaining_timeout/1, so a keep-alive answered every ten seconds cannot hold a thirty-second answer timeout open forever — a bug wearing a feature's clothes.

Called by the code on_events generates; a machine never writes it.

goto(target_ast, desc \\ nil, type \\ nil)

(macro)

Transition to another state. target may be a state name, next (the next declared state), loop (re-enter the current state) or back (return to the state the FSM was in before entering this one). desc is an optional short description of the triggering event, used for logging and shown in the monitor. type optionally categorizes that event (:sip, :media, :timer, :http, :db, …) — recorded by the monitor to drive the future sequence diagram, mirroring the command typing of the application's own verbs.

When type is omitted and the goto runs inside a on_events clause, the type is inferred from the matched event (:media for {:ms_event, …}, :sip for anything the host classifies). An explicit type always wins.

goto call_answered, "200 OK", :sip
goto start_play, "media connected", :media

back reads sip_ctx.laststate, a single slot the runner writes on every transition that actually changes state — goto loop and stay leave it alone. It is one slot, not a stack: two consecutive goto back toggle between two states. Using it with no previous state (from initial_state) aborts the scenario as a failure.

Aborts the scenario as a failure if sip_ctx.lasterr is not :ok.

notify(child_name, payload)

(macro)

Send an application message to a named child sub-FSM. The child receives it as {:parent_msg, payload}. Unknown name → logged and ignored.

notify_parent(payload)

(macro)

Send an application message to the parent FSM. The parent receives it as {:child_msg, <our name>, payload} — the name the parent assigned with as:, so it matches a stable literal in every state. No-op when this scenario has no parent (so the same scenario also runs standalone).

on_events(blocks)

(macro)

Like Elixir's receive, but each clause records the type of the matched event so the trailing goto is automatically categorized (no need to pass the type explicitly). The type is inferred from the clause pattern: {:ms_event, …}:media, any other tuple its host recognises ({100, …}, {:BYE, …}) → :sip.

on_events do
  {200, rsp, trans, _dlg} -> process_invite_reply(rsp, trans); goto answered, "200 OK"
  {:ms_event, _c, :ice_connected} -> goto play, "media connected"
after
  30_000 -> scenario_failure("timeout")
end

A clause ending with stay/2 re-enters this same wait instead of leaving the state. The after clause is therefore the deadline of the whole wait, not of one event: its expression is evaluated once, when the block is entered, and a stay comes back with the time that is left.

on_shutdown(list)

(macro)

Declare an optional handler run when a cooperative shutdown is requested ({:scenario_ctl, :shutdown, _} received inside an on_events). Compiles to the reserved :__shutdown__ state; its body must end with a transition macro (scenario_aborted/1 recommended). When omitted, the runner terminates the scenario with the :aborted outcome by default.

on_shutdown do
  # release app resources, send a BYE, ...
  scenario_aborted("controller asked to stop")
end

register_namespace(caller_module, namespace)

@spec register_namespace(module(), atom()) :: :ok | nil

Teach a machine the namespace of a service building block it is about to call, for the whole module rather than from one state on.

Called by a face module's __using__ — a module that publishes a block's verb — because on_events cannot classify a block's return from a table: the namespace is the block author's word, and an unrecognised leading atom falls through to the host's fallback for an unrecognised type — which a renderer draws as an arrow from the peer, and a block's return came from nobody. sbb_fsm/2 records it on its own.

remaining_timeout(deadline)

@spec remaining_timeout(integer() | :infinity) :: non_neg_integer() | :infinity

What is left of a deadline from deadline/1, floored at zero — the timeout a stay re-enters its wait with.

sbb_data_get(key)

(macro)

Read a key from this block's private sandbox — appdata is shared with the host (that is the point), but a block's scratch space is its own, so it cannot collide with a host key of the same name.

sbb_data_set(key, value)

(macro)

Write a key into this block's private sandbox. Anything the block wants to hand over goes in the event it returns, or under a documented key of the shared appdata — not here.

sbb_fsm(module, opts \\ [])

(macro)

Enter a service building block: the current process runs module's FSM until it hands control back, then execution continues on the next line of this state body. Not a spawn — no second process, no second set of legs. The block sees this scenario's context, dialogs and mailbox, because it is this process (design docs/design/DESIGN-SBB.md).

Options:

  • timeout: — completion deadline in ms, overriding the block's own @sbb_timeout. On expiry the block returns its @sbb_timeout_event exactly as if it had returned it itself.
  • args: — map seeding the block's private sandbox, read inside it with sbb_data_get/1. Each key the block declares in @sbb_args may also be written plainly at the call site — authenticate(realm: "example.com") — which is what a face publishes; a key no block declares raises.
  • resume:true keeps the sandbox from a previous run of the same block instead of clearing it. For a block designed to be re-entered after an interruption; the default is a clean slate, so a serial hunt calling a block target after target does not inherit the previous attempt.

The block talks back through events, matched in the on_events that follows:

state place_call do
  sbb_fsm SBB.Call, args: %{dest: dest}, timeout: 60_000

  on_events do
    {:call, :connected, uri} -> goto talking, "answered by #{uri}"
    {:call, :rejected, code, _reason} -> goto failed, "callee said #{code}"
  end
end

Only valid in a state body, never inside an on_events clause: that clause's deadline is absolute, so a block called from one would burn the host's remaining timeout while it runs. Give the block its own state.

sbb_return(event)

(macro)

End this service building block, posting event to the process and handing control back to the state that called it. The host matches event in its own on_events, like any other event — and behind anything this block left unconsumed, since it goes to the back of the mailbox.

This, not scenario_success, is how a block returns. The three terminals keep their ordinary meaning inside a block: they tear down the whole stack, host included.

Every branch of an SBB ends on sbb_return or on a terminal; a branch that falls through leaves the host waiting for an event nobody will send.

scenario_aborted(reason \\ "", type \\ nil)

(macro)

Terminate the scenario as aborted — a controller-driven wind-down (e.g. a cooperative shutdown), distinct from a failure so it is not counted as one. Typically used as the last statement of an on_shutdown block.

scenario_failure(reason \\ "", type \\ nil)

(macro)

Terminate the scenario as a failure, storing reason in the context.

scenario_success(reason \\ "", type \\ nil)

(macro)

Terminate the scenario successfully, transitioning to the success state.

spawn_fsm(target, opts \\ [])

(macro)

Spawn another scenario as a sub finite-state machine (a separate process, required because each FSM owns its own mailbox). Hands the child our PID and a local name so the two can exchange messages with notify/2 / notify_parent/1.

Named after fx.spawn of the TypeScript FSL, which spawns a child machine on the same contract (finite-state-language, spec §8.1) — the two dialects keep one name per concept.

target is either a compiled scenario module or a path to a .exs scenario file. Options:

  • as:required local name (atom) used to address the child and to tag the messages it sends back.
  • args: — optional map merged into the child context appdata.

The child handle is stored in sip_ctx.appdata[:__children__], so it survives across states; the macro rebinds sip_ctx like ctx_set.

state initial_state do
  spawn_fsm UAS.AutoAnswer, as: :callee, args: %{play: "ring.wav"}
  goto calling
end

state(name_ast, list)

(macro)

Declare a state of the finite state machine. The body must end with a transition macro (goto / scenario_success / scenario_failure).

stay(desc \\ nil, type \\ nil)

(macro)

Consume the matched event and keep waiting on the same on_events, without re-entering the state: the state body is not re-executed, so its side effects (sending a request, arming a timer, allocating media) are not replayed. This is what goto loop cannot do.

state call_established do
  on_events do
    {:MESSAGE, req, trans, _dlg} ->
      reply_request(req, trans, 200, "OK")
      stay "in-dialog MESSAGE"

    {:BYE, _req, _trans, _dlg} ->
      goto hangup, "BYE"
  end
end

desc and type behave as in goto/3: the transition is logged as (state) -> (state) and reported to FSL.Monitor, so a scenario whose whole activity is stay never looks frozen in the live view. Like goto, it aborts the scenario as a failure when sip_ctx.lasterr is not :ok.

The enclosing after timeout is not re-armed: it is the deadline of the state, computed once when the on_events is entered, and a stay re-enters the wait with the time that is left. stay is only meaningful inside an on_events clause; anywhere else the scenario stops as a failure.

sub_fsm(target, opts \\ [])

(macro)
This macro is deprecated. Use spawn_fsm/2 instead.

Deprecated spelling of spawn_fsm/2, kept so scenarios written before 1.5.0 keep loading. Same semantics, including the path resolution.