Declares a service building block: a reusable fragment of a flow, written in FSL, that other machines call.
A block is the subroutine of the language. A machine enters one from a state; the block then runs a machine of its own, in the caller's process, until it hands control back by returning one event. Use it for a sequence that several machines need and none of them should have to get right twice — establishing a call, running a menu, collecting credentials.
Contrast with spawn_fsm, which starts a second machine in a process of its
own: a block has no concurrency and no mailbox of its own, and the caller is
suspended at the call site until it returns.
Writing one
defmodule MyApp.Confirming do
use FSL.Block
@sbb_namespace :confirm
@sbb_returns [
accepted: "the far end agreed — %{}",
declined: "it said no — %{reason}"
]
@sbb_args [prompt: "what to ask"]
@sbb_timeout 30_000
state initial_state do
notify_parent({:asking, sbb_data_get(:prompt)})
goto waiting
end
state waiting do
on_events do
{:parent_msg, :yes} -> sbb_return({:confirm, :accepted, %{}})
{:parent_msg, {:no, why}} -> sbb_return({:confirm, :declined, %{reason: why}})
end
end
endCalling one
sbb_fsm/2 enters the block; the event it returns is matched in the
on_events that follows, like any other event:
state asking_the_user do
sbb_fsm MyApp.Confirming, prompt: "Delete everything?"
on_events do
{:confirm, :accepted, _data} ->
goto deleting, "confirmed"
{:confirm, :declined, %{reason: why}} ->
goto cancelled, "declined: #{why}"
# Bounded blocks add `:timeout` to their vocabulary, so this arm always
# exists and the caller needs no `after` clause of its own.
{:confirm, :timeout, _data} ->
goto cancelled, "no answer"
end
endTwo rules the compiler enforces at the call site:
sbb_fsmbelongs in a state body, not in anon_eventsclause. A clause's deadline is absolute, so a block called from one would spend the caller's remaining time while it ran;- an outcome the block did not declare is a compile error, so a typo cannot become a caller waiting on a deadline for an event nobody will send.
Keys declared in @sbb_args are written plainly at the call site, as above;
args: %{prompt: "…"} is the same thing spelled as a map. A key the block does
not declare raises rather than becoming a sandbox entry nobody reads.
What a block returns
Every block returns {namespace, outcome, data} — the namespace it
declares, an outcome atom, and a map. The shape is fixed so that a block can
learn to report one more thing without breaking a host that matches it: a new
key in data is invisible to whoever does not read it, where a fifth tuple
element would be a compile error in every caller.
@sbb_returns declares that vocabulary, and it is enforced rather than
documentary: sbb_return/1 refuses an outcome that is not in it, at compile
time. @sbb_namespace defaults to the block's last name segment, underscored.
When the block is bounded (the default), :timeout is added to the vocabulary
for free and {namespace, :timeout, %{block: module}} is what the host
receives on expiry, unless @sbb_timeout_event says otherwise.
What a block takes
@sbb_args declares the keys a caller may seed the sandbox with, and it is no
more decoration than @sbb_returns: a call site names them plainly —
authenticate(realm: "example.com"), call(peer: peer) — and a key no block
declares raises instead of becoming a sandbox entry nobody reads. args: %{…}
is the same thing spelled as a map, and both may be mixed.
What a block is, exactly
The same language as any machine — same state, same on_events, and whatever
verbs the binding brought — with two differences:
- it gains
sbb_return/1,sbb_data_get/1andsbb_data_set/2; - it has no
run/1, so it can never be mistaken for the machine of the.exsfile that declares it.FSL.Loaderpicks the first module exportingrun/1, and a block declared above the machine in the same file would otherwise be loaded and run as that machine.
It runs in the calling machine's own process, on that machine's own
mailbox and resources: a block observes and acts on what its host is doing,
which is what separates it from spawn_fsm/2 and its independent child.
Terminals written inside a block (scenario_failure, scenario_aborted) keep
their ordinary meaning and tear the host down too.
:ctx_var and :host are passed through to FSL.Machine, so an application's
facade declares them once for its blocks as it does for its machines.
cleanup/1 — a block releases what it reserved, on every way out
A block may define cleanup/1. It runs on every exit from the block, not
only on the happy one:
| Leaving by | cleanup/1 runs |
|---|---|
sbb_return/1 | yes |
| the block's own deadline | yes |
a terminal written inside it (scenario_failure, scenario_aborted) | yes, then the terminal continues to the root |
| a cooperative shutdown reaching it | yes, then the wind-down continues into the host |
| an enclosing block's deadline passing through | yes — this block is abandoned too |
That last column is the point. A block is a subroutine of a machine that is
often dying: "the host is tearing down anyway" is not a reason to skip the
release, because the host's own cleanup/1 does not know what a block took.
Without this, every branch of every block had to remember — and a branch that
forgot leaked with nothing in the log, which is exactly the silence this layer
exists to remove.
It runs while the block is still on the reporting stack, so a command it issues is attributed to the block rather than to the host state control is about to return to.
Unlike a machine's cleanup/1, whose return the runner discards, a block's is
threaded: what a block reserved lives in the host's context, so releasing
it means clearing it there.
def cleanup(ctx) do
case sbb_data_get_in(ctx, :handle) do
nil -> ctx
h -> release(h) && FSL.Context.appdata_set(ctx, :handle, nil)
end
endA block that has nothing to hand back writes :ok; anything that is not a
context is ignored and the context passes through unchanged. And a cleanup/1
that raises is logged and swallowed: it runs on the failure path, so it must
not turn a clean return into an exception nor swallow a terminal on its way to
the root.
The hook is the one the TypeScript dialect had first; the cross-language spec
recorded it as a commitment this side owed (spec/fsl-js-ts.md §12.4).
Design: docs/design.md §6.