State Machines
DexStateMachine provides a small table-driven state machine for
asynchronous workflows.
Use it when an object has a finite set of modes and transitions between those modes may need to wait for asynchronous work. Common examples include connection lifecycles, authentication handoff, device setup, or UI workflows where a request must move through well-defined states.
The state machine serializes transition requests internally, runs transition callbacks from a fiber, and rejects attempts to use edges that were not declared up front.
Defining States
States are represented by a GEnum type. This lets libdex validate transition
tables and return enum values from dex_state_machine_transition().
typedef enum
{
PASSWORD_DAEMON_INITIAL,
PASSWORD_DAEMON_PREPARE,
PASSWORD_DAEMON_READY,
PASSWORD_DAEMON_LOCKED,
PASSWORD_DAEMON_UNLOCKED,
} PasswordDaemonState;
The enum must have a registered GType, for example using
G_DEFINE_ENUM_TYPE().
Defining Transitions
A transition table declares every supported edge.
static const DexStateTransition password_daemon_transitions[] = {
{ PASSWORD_DAEMON_INITIAL, PASSWORD_DAEMON_PREPARE, enter_prepare },
{ PASSWORD_DAEMON_PREPARE, PASSWORD_DAEMON_READY, enter_ready },
{ PASSWORD_DAEMON_READY, PASSWORD_DAEMON_LOCKED, enter_locked },
{ PASSWORD_DAEMON_LOCKED, PASSWORD_DAEMON_UNLOCKED, enter_unlocked },
{ PASSWORD_DAEMON_UNLOCKED, PASSWORD_DAEMON_LOCKED, enter_locked },
};
The table is copied and validated when the DexStateMachine is created.
Each entry must use valid enum values and have a transition callback. Duplicate
edges are rejected.
Once created, the transition table is immutable. There is no API to add transitions while the state machine is running.
Creating a State Machine
The constructor accepts user data that is passed to every transition callback. Use normal C ownership rules for this data. If the state machine should keep an object alive, pass a strong reference and destroy notify. If it should not, pass a weak-reference holder instead.
daemon->state_machine =
dex_state_machine_new (PASSWORD_DAEMON_TYPE_STATE,
PASSWORD_DAEMON_INITIAL,
password_daemon_transitions,
G_N_ELEMENTS (password_daemon_transitions),
NULL,
0,
g_object_ref (daemon),
g_object_unref);
Writing Transition Callbacks
Transition callbacks use DexStateTransitionFunc.
static gboolean
enter_prepare (DexStateTransitionContext *context,
gpointer user_data,
GError **error)
{
PasswordDaemon *daemon = user_data;
g_autoptr(GSocket) control = NULL;
g_assert_cmpuint (dex_state_transition_context_get_from (context),
==,
PASSWORD_DAEMON_INITIAL);
g_assert_cmpuint (dex_state_transition_context_get_to (context),
==,
PASSWORD_DAEMON_PREPARE);
control = dex_await_object (create_control_socket (daemon), error);
if (control == NULL)
return FALSE;
daemon->control = g_steal_pointer (&control);
return TRUE;
}
Callbacks are run from a fiber, so they may call dex_await() and
dex_await_*() directly. They should return TRUE on success or FALSE with
error set on failure.
If a callback succeeds without calling
dex_state_transition_context_set_state(), the state machine commits the
target state from dex_state_transition_context_get_to(). If the callback
fails before setting state, the state remains unchanged.
Callbacks may also call dex_state_transition_context_continue_to() to
continue through another declared edge before queued transition requests are processed.
Requesting Transitions
Call dex_state_machine_transition() to request a transition. The returned
future resolves to the final enum state or rejects with an error.
DexFuture *future =
dex_state_machine_transition (daemon->state_machine,
PASSWORD_DAEMON_LOCKED);
Requests are serialized. If multiple callers request transitions at once, only one transition fiber runs at a time.
Use dex_state_machine_get_requested_state() to read the most recent
valid target passed to dex_state_machine_transition(). This is useful
when an owner wants to expose requested state separately from current state.
It is updated when the request is made, before the transition runs. It does
not mean that the requested transition will succeed, and it may differ from
the current state after a rejected transition.
Unsupported transitions reject with
DEX_ERROR_INVALID_TRANSITION. For example, a request to go directly from PASSWORD_DAEMON_INITIAL to PASSWORD_DAEMON_UNLOCKED will fail unless
that edge exists in the transition table.
Waiting for State
Call dex_state_machine_wait_for_state() when code outside the transition
callback needs to wait until the current state reaches a specific value.
DexFuture *future =
dex_state_machine_wait_for_state (daemon->state_machine,
PASSWORD_DAEMON_READY);
The returned future resolves immediately if the state machine is already in
that state. Otherwise it resolves the next time the state is set to that value,
including intermediate states exposed by
dex_state_transition_context_set_state().
Waiting does not request a transition. Pair it with
dex_state_machine_transition() when the caller also needs to initiate the
state change.
Interrupting Active Transitions
Long-running transition callbacks can opt in to cooperative interruption with
dex_state_transition_context_wait_for_interrupt(). The returned future
resolves when another caller calls dex_state_machine_interrupt().
static gboolean
enter_unlocked (DexStateTransitionContext *context,
gpointer user_data,
GError **error)
{
PasswordDaemon *daemon = user_data;
if (!dex_await (dex_future_first (
dex_state_transition_context_wait_for_interrupt (context),
password_daemon_read_loop (daemon),
NULL),
error))
return FALSE;
return dex_state_transition_context_continue_to (context,
PASSWORD_DAEMON_LOCKED,
error);
}
Interrupting does not request a transition, change state, or skip the transition callback. It only resolves the active context’s interrupt future. The callback decides whether to continue to another state, return successfully, or fail with an error.
If dex_state_machine_interrupt() is called before the active callback
requests the interrupt future, the interrupt is remembered and
dex_state_transition_context_wait_for_interrupt() returns an already
resolved future. If the callback completes before interruption, the interrupt
future rejects with G_IO_ERROR_CANCELLED.
Updating State During a Transition
DexStateTransitionContext gives callbacks access to both the declared
transition edge and the real current state. The context is only valid for the
duration of the transition callback and must not be stored.
dex_state_transition_context_get_from() and
dex_state_transition_context_get_to() are fixed for the callback. They
describe the transition table edge being executed. They do not change if the
callback updates the state.
dex_state_transition_context_get_state() reads the real current state
from the state machine. dex_state_transition_context_set_state() updates
that real state immediately. This is useful for quiescent transitions that need
to expose progress through intermediate states in one pass. set_state() may
only be used while the transition callback is active.
static gboolean
enter_prepare (DexStateTransitionContext *context,
gpointer user_data,
GError **error)
{
PasswordDaemon *daemon = user_data;
dex_state_transition_context_set_state (context, PASSWORD_DAEMON_PREPARE);
if (!dex_await (password_daemon_prepare (daemon), error))
return FALSE;
dex_state_transition_context_set_state (context, PASSWORD_DAEMON_READY);
return TRUE;
}
With this table, requesting INITIAL -> PREPARE runs enter_prepare() and
resolves with READY when the callback succeeds.
static const DexStateTransition password_daemon_transitions[] = {
{ PASSWORD_DAEMON_INITIAL, PASSWORD_DAEMON_PREPARE, enter_prepare },
};
If a callback calls dex_state_transition_context_set_state(), those
updates are not rolled back automatically if the callback later fails. They are
real state changes. Set an explicit failure state before returning FALSE when
that is the state machine behavior you want.
GObject State Properties
DexStateMachine is not a GObject. When a GObject owns a state
machine, keep the state machine private and expose state from the owning object.
Install a readable enum property on the owner. Transition callbacks will emit
notify::state explicitly when observable state changes.
enum {
PROP_0,
PROP_STATE,
N_PROPS
};
static GParamSpec *properties[N_PROPS];
static PasswordDaemonState
password_daemon_get_state (PasswordDaemon *daemon)
{
return dex_state_machine_get_state (daemon->state_machine);
}
static void
password_daemon_get_property (GObject *object,
guint prop_id,
GValue *value,
GParamSpec *pspec)
{
PasswordDaemon *daemon = PASSWORD_DAEMON (object);
switch (prop_id)
{
case PROP_STATE:
g_value_set_enum (value, password_daemon_get_state (daemon));
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
}
}
static void
password_daemon_class_init (PasswordDaemonClass *daemon_class)
{
GObjectClass *object_class = G_OBJECT_CLASS (daemon_class);
object_class->get_property = password_daemon_get_property;
properties[PROP_STATE] =
g_param_spec_enum ("state", NULL, NULL,
PASSWORD_DAEMON_TYPE_STATE,
PASSWORD_DAEMON_INITIAL,
(G_PARAM_READABLE |
G_PARAM_STATIC_STRINGS));
g_object_class_install_properties (object_class, N_PROPS, properties);
}
If the owner stores a strong reference to the state machine, do not also pass a strong reference to the owner as transition user data. That creates a reference cycle:
daemon -> state_machine -> daemon
Use a small weak-reference holder instead.
typedef struct
{
GWeakRef daemon;
} PasswordDaemonTransitionData;
static PasswordDaemonTransitionData *
password_daemon_transition_data_new (PasswordDaemon *daemon)
{
PasswordDaemonTransitionData *data;
data = g_new0 (PasswordDaemonTransitionData, 1);
g_weak_ref_init (&data->daemon, daemon);
return data;
}
static void
password_daemon_transition_data_free (PasswordDaemonTransitionData *data)
{
g_weak_ref_clear (&data->daemon);
g_free (data);
}
daemon->state_machine =
dex_state_machine_new (PASSWORD_DAEMON_TYPE_STATE,
PASSWORD_DAEMON_INITIAL,
password_daemon_transitions,
G_N_ELEMENTS (password_daemon_transitions),
NULL,
0,
password_daemon_transition_data_new (daemon),
(GDestroyNotify)password_daemon_transition_data_free);
When transition callbacks update the state machine state directly, notify the owner at the same time. A helper keeps this consistent.
static void
password_daemon_set_state (PasswordDaemon *daemon,
DexStateTransitionContext *context,
PasswordDaemonState state)
{
if (dex_state_transition_context_get_state (context) == state)
return;
dex_state_transition_context_set_state (context, state);
g_object_notify_by_pspec (G_OBJECT (daemon), properties[PROP_STATE]);
}
Then transition callbacks can acquire the owner from the weak reference and use
the helper for every state change that should be visible through :state.
static gboolean
enter_prepare (DexStateTransitionContext *context,
gpointer user_data,
GError **error)
{
PasswordDaemonTransitionData *data = user_data;
g_autoptr(PasswordDaemon) daemon = NULL;
daemon = g_weak_ref_get (&data->daemon);
if (daemon == NULL)
{
g_set_error_literal (error,
G_IO_ERROR,
G_IO_ERROR_CANCELLED,
"PasswordDaemon was disposed");
return FALSE;
}
password_daemon_set_state (daemon, context, PASSWORD_DAEMON_PREPARE);
if (!dex_await (password_daemon_prepare (daemon), error))
return FALSE;
password_daemon_set_state (daemon, context, PASSWORD_DAEMON_READY);
return TRUE;
}
If :state notifications matter, do not rely on the state machine’s automatic
target commit for those states. The automatic commit happens after the
transition callback returns, where the owner has no callback-local opportunity
to emit notify::state. Call dex_state_transition_context_set_state()
through your notification helper instead.
The same rule applies when using dex_state_transition_context_continue_to().
If the current edge target should be observable, set and notify it before calling continue_to(). The continued edge callback should notify its own
observable state changes.
Notifications are emitted from the transition fiber. If signal handlers for the owner must run on a specific main context or thread, create and use the state machine with a scheduler that runs there.
If callers need to observe both intent and progress, expose a separate
:requested-state property on the owner and read it from
dex_state_machine_get_requested_state(). Notify that property from the
owner method that calls dex_state_machine_transition(), because
DexStateMachine is not a GObject.
Continuing Through Edges
dex_state_transition_context_continue_to() lets a transition callback run
another declared edge immediately while the state machine still owns its
serialization slot. It is synchronous composition: the next transition
callback is called before continue_to() returns, and queued transition
requests wait until the continuation chain finishes.
static gboolean
enter_prepare (DexStateTransitionContext *context,
gpointer user_data,
GError **error)
{
PasswordDaemon *daemon = user_data;
if (!dex_await (password_daemon_prepare (daemon), error))
return FALSE;
return dex_state_transition_context_continue_to (context,
PASSWORD_DAEMON_READY,
error);
}
With this table, a request for INITIAL -> PREPARE runs enter_prepare(), commits PREPARE, then immediately runs enter_ready() before any queued
transition request can run.
static const DexStateTransition password_daemon_transitions[] = {
{ PASSWORD_DAEMON_INITIAL, PASSWORD_DAEMON_PREPARE, enter_prepare },
{ PASSWORD_DAEMON_PREPARE, PASSWORD_DAEMON_READY, enter_ready },
};
If the callback has not called dex_state_transition_context_set_state(),
continue_to() validates the next edge before committing the current edge. An
unsupported continuation returns FALSE with
DEX_ERROR_INVALID_TRANSITION. If the next callback fails after the
current edge has been committed, that state change is not rolled back.
If a continued transition callback uses dex_await(), the same transition
fiber suspends and later resumes; the main context is not blocked, but the
state machine queue is still held. Chained continuations use ordinary C calls,
so this API is intended for short, bounded handoffs between edges rather than
unbounded graph traversal.
Circular State Machines
Circular graphs are valid. For example, a lock state machine can declare both directions explicitly.
static const DexStateTransition lock_transitions[] = {
{ PASSWORD_DAEMON_LOCKED, PASSWORD_DAEMON_UNLOCKED, unlock },
{ PASSWORD_DAEMON_UNLOCKED, PASSWORD_DAEMON_LOCKED, lock },
};
The state machine does not reject cycles. It rejects only invalid states, duplicate edges at construction time, and unsupported edges at transition time.
Error Handling
There are three common failure cases:
- Construction fails if the transition table is invalid.
- Transition requests reject with
DEX_ERROR_INVALID_TRANSITIONwhen no declared edge exists. - Transition callbacks may fail with their own error.
Callback failures leave the state unchanged only if the callback has not
already called dex_state_transition_context_set_state(). Invalid explicit
state updates are programmer errors.