systant/server/deps/gen_state_machine/lib/gen_state_machine.ex
ryan b6769abbe9 Restructure as monorepo and add flake packages/apps
- Move Elixir code to server/ subdirectory for monorepo structure
- Update flake.nix to provide packages and apps outputs for nix run support
- Update nix/package.nix to accept src parameter instead of fetchgit
- Add NixOS module export for easy consumption

Now supports: nix run, nix build, and nix develop from git repo

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-02 19:54:17 -07:00

774 lines
27 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule GenStateMachine do
@moduledoc """
A behaviour module for implementing a state machine.
The advantage of using this module is that it will have a standard set of
interface functions and include functionality for tracing and error reporting.
It will also fit into a supervision tree.
## Example
The `GenStateMachine` behaviour abstracts the state machine. Developers are only
required to implement the callbacks and functionality they are interested in.
Let's start with a code example and then explore the available callbacks.
Imagine we want a `GenStateMachine` that works like a switch, allowing us to
turn it on and off, as well as see how many times the switch has been turned
on:
defmodule Switch do
use GenStateMachine
# Callbacks
def handle_event(:cast, :flip, :off, data) do
{:next_state, :on, data + 1}
end
def handle_event(:cast, :flip, :on, data) do
{:next_state, :off, data}
end
def handle_event({:call, from}, :get_count, state, data) do
{:next_state, state, data, [{:reply, from, data}]}
end
end
# Start the server
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, 0})
# This is the client
GenStateMachine.cast(pid, :flip)
#=> :ok
GenStateMachine.call(pid, :get_count)
#=> 1
We start our `Switch` by calling `start_link/3`, passing the module with the
server implementation and its initial argument, a tuple where the first element
is the initial state and the second is the initial data. We can primarily
interact with the state machine by sending two types of messages. **call**
messages expect a reply from the server (and are therefore synchronous) while
**cast** messages do not.
Every time you do a `call/3` or a `cast/2`, the message will be handled by
`handle_event/4`.
We can also use the `:state_functions` callback mode instead of the default,
which is `:handle_event_function`:
defmodule Switch do
use GenStateMachine, callback_mode: :state_functions
def off(:cast, :flip, data) do
{:next_state, :on, data + 1}
end
def off({:call, from}, :get_count, data) do
{:keep_state_and_data, [{:reply, from, data}]}
end
def on(:cast, :flip, data) do
{:next_state, :off, data}
end
def on({:call, from}, :get_count, data) do
{:keep_state_and_data, [{:reply, from, data}]}
end
end
# Start the server
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, 0})
# This is the client
GenStateMachine.cast(pid, :flip)
#=> :ok
GenStateMachine.call(pid, :get_count)
#=> 1
Again, we start our `Switch` by calling `start_link/3`, passing the module
with the server implementation and its initial argument, and interacting with
it via **call** and **cast**.
However, in `:state_functions` callback mode, every time you do a `call/3` or
a `cast/2`, the message will be handled by the `state_name/3` function which
is named the same as the current state.
## Callbacks
In the default `:handle_event_function` callback mode, there are 4 callbacks
required to be implemented. By adding `use GenStateMachine` to your module,
Elixir will automatically define all 4 callbacks for you, leaving it up to you
to implement the ones you want to customize.
In the `:state_functions` callback mode, there are 3 callbacks required to be
implemented. By adding `use GenStateMachine, callback_mode: :state_functions`
to your module, Elixir will automatically define all 3 callbacks for you,
leaving it up to you to implement the ones you want to customize, as well as
`state_name/3` functions named the same as the states you wish to support.
It is important to note that the default implementation of the `code_change/4`
callback results in an `:undefined` error. This is because `code_change/4` is
related to the quite difficult topic of hot upgrades, and if you need it, you
should really be implementing it yourself. In normal use this callback will
not be invoked.
## State Enter Callbacks
If you wish, you can register to receive callbacks when you enter a state by
adding `:state_enter` to your `callback_mode`:
defmodule Switch do
use GenStateMachine, callback_mode: [:handle_event_function, :state_enter]
def handle_event(:enter, _event, state, data) do
{:next_state, state, %{data | enters: data.enters + 1}}
end
def handle_event(:cast, :flip, :off, data) do
{:next_state, :on, %{data | flips: data.flips + 1}}
end
def handle_event(:cast, :flip, :on, data) do
{:next_state, :off, data}
end
def handle_event({:call, from}, :get_count, _state, data) do
{:keep_state_and_data, [{:reply, from, data}]}
end
end
# Start the server
{:ok, pid} = GenStateMachine.start_link(Switch, {:off, %{enters: 0, flips: 0}})
# This is the client
GenStateMachine.cast(pid, :flip)
GenStateMachine.cast(pid, :flip)
#=> :ok
GenStateMachine.call(pid, :get_count)
#=> %{enters: 3, flips: 1}
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-state_enter)
for more details.
## Name Registration
Both `start_link/3` and `start/3` support registering the `GenStateMachine`
under a given name on start via the `:name` option. Registered names are also
automatically cleaned up on termination. The supported values are:
* an atom - the `GenStateMachine` is registered locally with the given name
using `Process.register/2`.
* `{:global, term}`- the `GenStateMachine` is registered globally with the
given term using the functions in the `:global` module.
* `{:via, module, term}` - the `GenStateMachine` is registered with the given
mechanism and name. The `:via` option expects a module that exports
`register_name/2`, `unregister_name/1`, `whereis_name/1` and `send/2`.
One such example is the `:global` module which uses these functions
for keeping the list of names of processes and their associated pid's
that are available globally for a network of Erlang nodes.
For example, we could start and register our Switch server locally as follows:
# Start the server and register it locally with name MySwitch
{:ok, _} = GenStateMachine.start_link(Switch, {:off, 0}, name: MySwitch)
# Now messages can be sent directly to MySwitch
GenStateMachine.call(MySwitch, :get_count) #=> 0
Once the server is started, the remaining functions in this module (`call/3`,
`cast/2`, and friends) will also accept an atom, or any `:global` or `:via`
tuples. In general, the following formats are supported:
* a `pid`
* an `atom` if the server is locally registered
* `{atom, node}` if the server is locally registered at another node
* `{:global, term}` if the server is globally registered
* `{:via, module, name}` if the server is registered through an alternative
registry
## Client / Server APIs
Although in the example above we have used `GenStateMachine.start_link/3` and
friends to directly start and communicate with the server, most of the
time we don't call the `GenStateMachine` functions directly. Instead, we wrap
the calls in new functions representing the public API of the server.
Here is a better implementation of our Switch module:
defmodule Switch do
use GenStateMachine
# Client
def start_link() do
GenStateMachine.start_link(Switch, {:off, 0})
end
def flip(pid) do
GenStateMachine.cast(pid, :flip)
end
def get_count(pid) do
GenStateMachine.call(pid, :get_count)
end
# Server (callbacks)
def handle_event(:cast, :flip, :off, data) do
{:next_state, :on, data + 1}
end
def handle_event(:cast, :flip, :on, data) do
{:next_state, :off, data}
end
def handle_event({:call, from}, :get_count, state, data) do
{:next_state, state, data, [{:reply, from, data}]}
end
def handle_event(event_type, event_content, state, data) do
# Call the default implementation from GenStateMachine
super(event_type, event_content, state, data)
end
end
In practice, it is common to have both server and client functions in
the same module. If the server and/or client implementations are growing
complex, you may want to have them in different modules.
## Receiving custom messages
The goal of a `GenStateMachine` is to abstract the "receive" loop for
developers, automatically handling system messages, support code change,
synchronous calls and more. Therefore, you should never call your own
"receive" inside the `GenStateMachine` callbacks as doing so will cause the
`GenStateMachine` to misbehave. If you want to receive custom messages, they
will be delivered to the usual handler for your callback mode with event_type
`:info`.
## Learn more
If you wish to find out more about gen_statem, the documentation and links
in Erlang can provide extra insight.
* [`:gen_statem` module documentation](http://erlang.org/doc/man/gen_statem.html)
* [gen_statem Behaviour OTP Design Principles](http://erlang.org/doc/design_principles/statem.html)
"""
@typedoc """
The term representing the current state.
In `:handle_event_function` callback mode, any term.
In `:state_functions` callback mode, an atom.
"""
@type state :: state_name | term
@typedoc """
The atom representing the current state in `:state_functions` callback mode.
"""
@type state_name :: atom
@typedoc """
The persistent data (similar to a GenServer's `state`) for the GenStateMachine.
"""
@type data :: term
@typedoc """
The source of the current event.
`{:call, from}` will be received as a result of a call.
`:cast` will be received as a result of a cast.
`:info` will be received as a result of any regular process messages received.
`:timeout` or `{:timeout, name}` will be received as a result of a `:timeout` action.
`:state_timeout` will be received as a result of a `:state_timeout` action.
`:internal` will be received as a result of a `:next_event` action.
See the Erlang
[documentation](https://erlang.org/doc/man/gen_statem.html#type-event_type)
for details.
"""
@type event_type :: :gen_statem.event_type()
@typedoc """
The callback mode for the GenStateMachine.
See the Example section above for more info.
"""
@type callback_mode_result :: :gen_statem.callback_mode_result()
@typedoc """
The message content received as the result of an event.
"""
@type event_content :: term
@typedoc """
State transition actions.
They may be invoked by returning them from a state function or init/1.
If present in a list of actions, they are executed in order, and any that set
transition options (postpone, hibernate, and timeout) override any previously
provided options of the same type.
If the state changes, the queue of incoming events is reset to start with the
oldest postponed.
All events added as a result of a `:next_event` action are inserted in the
queue to be processed before all other events. An event of type `:internal`
should be used when you want to reliably distinguish an event inserted this
way from an external event.
See the Erlang
[documentation](https://erlang.org/doc/man/gen_statem.html#type-action)
for the possible values.
"""
@type action :: :gen_statem.action()
@typedoc """
The return type of an event handler function.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-event_handler_result)
for a complete reference.
"""
@type event_handler_result(state) :: :gen_statem.event_handler_result(state)
@typedoc """
The return type of a state enter function.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-state_enter_result)
for a complete reference.
"""
@type state_enter_result(state) :: :gen_statem.state_enter_result(state)
@typedoc """
The return type when the server is started.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-start_ret)
for a complete reference.
"""
@type on_start :: :gen_statem.start_ret()
@typedoc """
The type of allowed server names.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#type-server_ref)
for a complete reference.
"""
@type server_ref :: :gen_statem.server_ref()
@doc """
Invoked when the server is started. `start_link/3` (or `start/3`) will
block until it returns.
`args` is the argument term (second argument) passed to `start_link/3`.
Returning `{:ok, state, data}` will cause `start_link/3` to return
`{:ok, pid}` and the process to enter its loop.
Returning `{:ok, state, data, actions}` is similar to `{:ok, state}`
except the provided actions will be executed.
Returning `:ignore` will cause `start_link/3` to return `:ignore` and the
process will exit normally without entering the loop or calling `terminate/2`.
If used when part of a supervision tree the parent supervisor will not fail
to start nor immediately try to restart the `GenStateMachine`. The remainder
of the supervision tree will be (re)started and so the `GenStateMachine`
should not be required by other processes. It can be started later with
`Supervisor.restart_child/2` as the child specification is saved in the parent
supervisor. The main use cases for this are:
* The `GenStateMachine` is disabled by configuration but might be enabled
later.
* An error occurred and it will be handled by a different mechanism than the
`Supervisor`. Likely this approach involves calling
`Supervisor.restart_child/2` after a delay to attempt a restart.
Returning `{:stop, reason}` will cause `start_link/3` to return
`{:error, reason}` and the process to exit with reason `reason` without
entering the loop or calling `terminate/2`.
This function can optionally throw a result to return it.
"""
@callback init(args :: term) :: :gen_statem.init_result(state)
@doc """
Whenever a `GenStateMachine` in callback mode `:state_functions` receives a
call, cast, or normal process message, a state function is called.
This spec exists to document the callback, but in actual use the name of the
function is probably not going to be `state_name`. Instead, there will be at
least one state function named after each state you wish to handle. See the
Examples section above for more info.
These functions can optionally throw a result to return it.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#Module:StateName-3)
for a complete reference.
"""
@callback state_name(:enter, old_state_name :: state_name, data) ::
state_enter_result(state_name)
@callback state_name(event_type, event_content, data) :: event_handler_result(state_name)
@doc """
Whenever a `GenStateMachine` in callback mode `:handle_event_function` (the
default) receives a call, cast, or normal process messsage, this callback will
be invoked.
This function can optionally throw a result to return it.
See the Erlang [documentation](https://erlang.org/doc/man/gen_statem.html#Module:handle_event-4)
for a complete reference.
"""
@callback handle_event(:enter, old_state :: state, state, data) :: state_enter_result(state)
@callback handle_event(event_type, event_content, state, data) :: event_handler_result(state)
@doc """
Invoked when the server is about to exit. It should do any cleanup required.
`reason` is exit reason, `state` is the current state, and `data` is the
current data of the `GenStateMachine`. The return value is ignored.
`terminate/2` is called if a callback (except `init/1`) returns a `:stop`
tuple, raises, calls `Kernel.exit/1` or returns an invalid value. It may also
be called if the `GenStateMachine` traps exits using `Process.flag/2` *and*
the parent process sends an exit signal.
If part of a supervision tree a `GenStateMachine`'s `Supervisor` will send an
exit signal when shutting it down. The exit signal is based on the shutdown
strategy in the child's specification. If it is `:brutal_kill` the
`GenStateMachine` is killed and so `terminate/2` is not called. However if it
is a timeout the `Supervisor` will send the exit signal `:shutdown` and the
`GenStateMachine` will have the duration of the timeout to call `terminate/2`
- if the process is still alive after the timeout it is killed.
If the `GenStateMachine` receives an exit signal (that is not `:normal`) from
any process when it is not trapping exits it will exit abruptly with the same
reason and so not call `terminate/2`. Note that a process does *NOT* trap
exits by default and an exit signal is sent when a linked process exits or its
node is disconnected.
Therefore it is not guaranteed that `terminate/2` is called when a
`GenStateMachine` exits. For such reasons, we usually recommend important
clean-up rules to happen in separated processes either by use of monitoring or
by links themselves. For example if the `GenStateMachine` controls a `port`
(e.g. `:gen_tcp.socket`) or `File.io_device`, they will be closed on receiving
a `GenStateMachine`'s exit signal and do not need to be closed in
`terminate/2`.
If `reason` is not `:normal`, `:shutdown` nor `{:shutdown, term}` an error is
logged.
This function can optionally throw a result, which is ignored.
"""
@callback terminate(reason :: term, state, data) :: any
@doc """
Invoked to change the state of the `GenStateMachine` when a different version
of a module is loaded (hot code swapping) and the state and/or data's term
structure should be changed.
`old_vsn` is the previous version of the module (defined by the `@vsn`
attribute) when upgrading. When downgrading the previous version is wrapped in
a 2-tuple with first element `:down`. `state` is the current state of the
`GenStateMachine`, `data` is the current data, and `extra` is any extra data
required to change the state.
Returning `{:ok, new_state, new_data}` changes the state to `new_state`, the
data to `new_data`, and the code change is successful.
Returning `reason` fails the code change with reason `reason` and the state
and data remains the same.
If `code_change/4` raises the code change fails and the loop will continue
with its previous state. Therefore this callback does not usually contain side
effects.
This function can optionally throw a result to return it.
"""
@callback code_change(old_vsn :: term | {:down, vsn :: term}, state, data, extra :: term) ::
{:ok, state, data}
| (reason :: term)
@doc """
Invoked in some cases to retrieve a formatted version of the `GenStateMachine`
status.
This callback can be useful to control the *appearance* of the status of the
`GenStateMachine`. For example, it can be used to return a compact
representation of the `GenStateMachines`'s state/data to avoid having large
terms printed.
* one of `:sys.get_status/1` or `:sys.get_status/2` is invoked to get the
status of the `GenStateMachine`; in such cases, `reason` is `:normal`
* the `GenStateMachine` terminates abnormally and logs an error; in such cases,
`reason` is `:terminate`
`pdict_state_and_data` is a three-element list `[pdict, state, data]` where
`pdict` is a list of `{key, value}` tuples representing the current process
dictionary of the `GenStateMachine`, `state` is the current state of the
`GenStateMachine`, and `data` is the current data.
This function can optionally throw a result to return it.
"""
@callback format_status(reason :: :normal | :terminate, pdict_state_and_data :: list) :: term
@optional_callbacks state_name: 3, handle_event: 4, format_status: 2
@doc false
defmacro __using__(opts) do
quote location: :keep, bind_quoted: [opts: opts] do
@behaviour GenStateMachine
{callback_mode, opts} = Keyword.pop(opts, :callback_mode, :handle_event_function)
@doc false
def callback_mode do
unquote(Macro.escape(callback_mode))
end
@doc false
def init({state, data}) do
{:ok, state, data}
end
@doc false
def terminate(_reason, _state, _data) do
:ok
end
@doc false
def code_change(_old_vsn, _state, _data, _extra) do
:undefined
end
unless Module.get_attribute(__MODULE__, :doc) do
@doc """
Returns a specification to start this module under a supervisor.
See `Supervisor`.
"""
end
def child_spec(init_arg) do
Supervisor.child_spec(
%{id: __MODULE__, start: {__MODULE__, :start_link, [init_arg]}},
unquote(Macro.escape(opts))
)
end
defoverridable init: 1, terminate: 3, code_change: 4, child_spec: 1
if :handle_event_function in List.wrap(callback_mode) do
@doc false
def handle_event(_event_type, _event_content, _state, _data) do
{:stop, :bad_event}
end
defoverridable handle_event: 4
end
end
end
@doc """
Starts a `GenStateMachine` process linked to the current process.
This is often used to start the `GenStateMachine` as part of a supervision
tree.
Once the server is started, the `init/1` function of the given `module` is
called with `args` as its arguments to initialize the server. To ensure a
synchronized start-up procedure, this function does not return until `init/1`
has returned.
Note that a `GenStateMachine` started with `start_link/3` is linked to the
parent process and will exit in case of crashes from the parent. The
`GenStateMachine` will also exit due to the `:normal` reasons in case it is
configured to trap exits in the `init/1` callback.
## Options
* `:name` - used for name registration as described in the "Name
registration" section of the module documentation
* `:timeout` - if present, the server is allowed to spend the given amount of
milliseconds initializing or it will be terminated and the start function
will return `{:error, :timeout}`
* `:debug` - if present, the corresponding function in the [`:sys`
module](http://www.erlang.org/doc/man/sys.html) is invoked
* `:spawn_opt` - if present, its value is passed as options to the
underlying process as in `Process.spawn/4`
## Return values
If the server is successfully created and initialized, this function returns
`{:ok, pid}`, where `pid` is the pid of the server. If a process with the
specified server name already exists, this function returns
`{:error, {:already_started, pid}}` with the pid of that process.
If the `init/1` callback fails with `reason`, this function returns
`{:error, reason}`. Otherwise, if it returns `{:stop, reason}`
or `:ignore`, the process is terminated and this function returns
`{:error, reason}` or `:ignore`, respectively.
"""
@spec start_link(module, any, GenServer.options()) :: on_start()
def start_link(module, args, options \\ []) do
{name, options} = Keyword.pop(options, :name)
if name do
name =
if is_atom(name) do
{:local, name}
else
name
end
:gen_statem.start_link(name, module, args, options)
else
:gen_statem.start_link(module, args, options)
end
end
@doc """
Starts a `GenStateMachine` process without links (outside of a supervision
tree).
See `start_link/3` for more information.
"""
@spec start(module, any, GenServer.options()) :: on_start()
def start(module, args, options \\ []) do
{name, options} = Keyword.pop(options, :name)
if name do
name =
if is_atom(name) do
{:local, name}
else
name
end
:gen_statem.start(name, module, args, options)
else
:gen_statem.start(module, args, options)
end
end
@doc """
Stops the server with the given `reason`.
The `terminate/2` callback of the given `server` will be invoked before
exiting. This function returns `:ok` if the server terminates with the
given reason; if it terminates with another reason, the call exits.
This function keeps OTP semantics regarding error reporting.
If the reason is any other than `:normal`, `:shutdown` or
`{:shutdown, _}`, an error report is logged.
"""
@spec stop(server_ref(), reason :: term, timeout) :: :ok
def stop(server, reason \\ :normal, timeout \\ :infinity) do
:gen_statem.stop(server, reason, timeout)
end
@doc """
Makes a synchronous call to the `server` and waits for its reply.
The client sends the given `request` to the server and waits until a reply
arrives or a timeout occurs. The appropriate state function will be called on
the server to handle the request.
`server` can be any of the values described in the "Name registration"
section of the documentation for this module.
## Timeouts
`timeout` is an integer greater than zero which specifies how many
milliseconds to wait for a reply, or the atom `:infinity` to wait
indefinitely. The default value is `:infinity`. If no reply is received within
the specified time, the function call fails and the caller exits.
If the caller catches an exit, to avoid getting a late reply in the caller's
inbox, this function spawns a proxy process that does the call. A late reply
gets delivered to the dead proxy process, and hence gets discarded. This is
less efficient than using `:infinity` as a timeout.
"""
@spec call(server_ref(), term, timeout) :: term
def call(server, request, timeout \\ :infinity) do
:gen_statem.call(server, request, timeout)
end
@doc """
Sends an asynchronous request to the `server`.
This function always returns `:ok` regardless of whether
the destination `server` (or node) exists. Therefore it
is unknown whether the destination `server` successfully
handled the message.
The appropriate state function will be called on the server to handle
the request.
"""
@spec cast(server_ref(), term) :: :ok
def cast(server, request) do
:gen_statem.cast(server, request)
end
@doc """
Sends replies to clients.
Can be used to explicitly send replies to multiple clients.
This function always returns `:ok`.
See `reply/2` for more information.
"""
@spec reply([:gen_statem.reply_action()] | :gen_statem.reply_action()) :: :ok
def reply(replies) do
:gen_statem.reply(replies)
end
@doc """
Replies to a client.
This function can be used to explicitly send a reply to a client that called
`call/3` when the reply cannot be specified in the return value of a state
function.
`client` must be the `from` element of the event type accepted by state
functions. `reply` is an arbitrary term which will be given
back to the client as the return value of the call.
Note that `reply/2` can be called from any process, not just the one
that originally received the call (as long as that process communicated the
`from` argument somehow).
This function always returns `:ok`.
## Examples
def handle_event({:call, from}, :reply_in_one_second, state, data) do
Process.send_after(self(), {:reply, from}, 1_000)
:keep_state_and_data
end
def handle_event(:info, {:reply, from}, state, data) do
GenStateMachine.reply(from, :one_second_has_passed)
end
"""
@spec reply(:gen_statem.from(), term) :: :ok
def reply(client, reply) do
:gen_statem.reply(client, reply)
end
end