Expand description
Proc-macro for embassy-supervisor.
supervisor_graph! is the single source of a task graph: it declares the
nodes (and an optional elastic pool), generates their statics, and computes
the topological order at compile time. A dependency cycle is a compile
error; an unknown dependency name is a compile error.
Surface (each item may be #[cfg(...)]-prefixed):
node NAME = Mode, deps: [A, B], spawn: <spawn>[, executor: EXEC][, disabled];
node NAME = Mode, deps: [A, B], task: <worker>[, pool_size: N][, executor: EXEC]
[, resources: [[#[cfg(..)]] RES: [local] [shared|consume] Type, ..]]
[, slot_timeout: MS][, disabled];
node NAME = Mode, deps: [A]; // neither => a parked node the app spawns
executor EXEC; // runtime-filled SendSpawner slot
pool NAME = [Mode, ..], deps: [A][, executor: EXEC], spawn: <fn> | task: <worker>,
[resources: [RES: [local] shared Type, ..],]
policy: [<Ty> =] <expr>, min: N, max: M[, slot_timeout: MS];deps: entries name a node or a pool; a pool dep resolves to that pool’s floor
member (member 0, the min-kept one), i.e. “start after the pool is up”. A repeated
dep or a redeclared node/pool name is a compile error.
An executor NAME; slot may carry #[cfg(...)], but validation does not model cfg
predicates: a node referencing a slot that is cfg’d out while the node is cfg’d
in surfaces as rustc’s cannot find value NAME, not a macro error — don’t gate an
executor slot more restrictively than the nodes that reference it.
executor EXEC; emits a pub static EXEC: SpawnerSlot; the app fills it with a
SendSpawner (InterruptExecutor::start, Spawner::make_send) before
Supervisor::start, and nodes carrying executor: EXEC spawn through it instead
of the supervisor’s own executor (their futures must be Send; an unfilled slot
fails the spawn with SpawnError::Busy).
A pool is emitted as ElasticPool<P>, so the macro needs the policy type P. By
default it derives P from a Ty::new(..)-shaped policy: value (e.g.
DeferredShrink::new(..) => P = DeferredShrink). Give policy: <Ty> = <expr> to
state P explicitly when the value isn’t that shape — a const, a free fn, a builder
chain (X::new(..).with(..)), or a qualified path.
spawn: takes a path or a partial call to a task fn taking the node first
(spawn: f => s.spawn(f(&NAME)?); spawn: f(a) => s.spawn(f(&NAME, a)?)), or,
for a node, a closure / ready spawn fn emitted verbatim (for anything that doesn’t
fit that shape). A pool’s spawn: is the same path/partial-call form with &POOL[j]
injected first, via a generated spawn_<pool>::<j> glue fn; a pool has no closure
form (members are instantiated per index).
task: takes the same path/partial-call forms but names a plain async worker
fn — possibly generic (turbofish or inferred) — instead of a hand-written
#[embassy_executor::task]. The macro stamps a concrete shell task per
declaration (embassy forbids generic tasks: one static TaskPool per concrete
future type), sized by pool_size: on a node (default 1) or by the member count
on a pool. Worker args are evaluated inside the shell — at the task’s first
poll, on the node’s own executor — so cross-node data should go through awaited
accessors, and a cross-core node builds its resources on its own core. task:
and spawn: are mutually exclusive; pool_size: requires task:.
Prefer task: — no attribute boilerplate, generic workers, auto-sized pool
shells, and it is the only form supporting resources:; the shell inlines into
the same poll and its TaskPool replaces the one the attribute would emit.
spawn: remains for: a fn that already carries #[embassy_executor::task] and
can’t be de-attributed (another crate); a task also spawned outside the graph
(sharing its one TaskPool instead of duplicating it as a shell); the verbatim
closure form (custom spawn-time logic); and args that must be evaluated at
spawn time on the supervisor’s executor rather than at the shell’s first poll.
Worked examples: README “spawn: vs task: — which to use”.
Two task: footguns, spelled out because nothing warns about either:
- a partial-call extra that can be missing at first poll is a task-side
panic, not a failed spawn — extras are for infallible accessors. A value
that might not exist yet belongs in
resources:(asharedentry for a fan-out handle), where the pre-spawn gate turns “missing” into a cleanSpawnError::Busy; - a verbatim-closure
spawn:node is invisible to the trace/name glue — the closure owns theSpawnToken, soadopt/stamp_nameis YOUR job inside it, and a stable proc-macro cannot emit a warning when you forget.
resources: [RES: [local] [shared|consume] Type, ..] (requires task:)
threads owned resources from main into the worker instead of re-acquiring
them inside the task (Peripherals::steal()). Each entry emits a
pub static RES slot at the declaration site; main moves the
resource in with RES.provide(..) (consuming the Peripherals field — the
compile-time exclusive-ownership guarantee), the generated glue take()s it just
before the spawn (an unprovided slot fails Supervisor::start with
SpawnError::Busy after a bounded wait — fail-closed, not a task-side panic),
and the shell passes the worker &mut Type (after the node arg, in declared
order, before any partial-call extras) and restore()s the value after the
worker returns, so a Terminate respawn re-takes the same instance. Take-kind
slot names are statics: unique across the graph. Entries may carry per-entry
#[cfg(...)] (the slot, gate, glue, shell param, and worker-call argument all
follow it — gate the worker fn’s matching parameter with the same #[cfg]).
Per-entry kind markers refine that default (order-free; local composes with
either of the mutually-exclusive consume/shared):
consume— the worker receives the value by value and no restore is emitted: the slot stays empty after the task exits, so the worker may drop the resource at teardown (a driver whoseDropreleases pins/DMA) and a respawn fail-closes (SpawnError::Busy) until the applicationprovide()s a fresh value — the pattern for resources rebuilt each run (e.g. radio driver objects that go stale across a power cycle).shared— a fan-out slot for aCopyhandle (anembassy_net::Stack, a&'staticshared-bus ref): the glue copies the value out non-destructively (get()—T: Copyenforced by its bound), the worker receives it by value, no restore, and the slot STAYS FILLED — so any number of nodes (and wholepools: the onlyresources:kind pools accept,task:pools only) may declare the SAME slot name. The static is emitted once, with the union of the declaring sites’ cfg predicates; every re-declaration must repeat the kinds + type verbatim.local— requires the non-defaultlocal-resourcesfeature (of the supervisor crate, forwarded here): the slot is the graph-site__SvLocalResourceSlottype instead ofResourceSlot— same protocol, noT: Sendbound, for!Senddriver handles (RefCell-/NoopRawMutex-based). Feature-gated because it is the one graph form that emitsunsafecode into the CONSUMER’S crate: anunsafe impl Syncwhose soundness is the single-core contract (allprovide/take/restoreof the slot on one core). It cannot combine withexecutor:(aSendSpawner-routed node needs aSendfuture — macro error), and a consumer crate forbiddingunsafe_codecannot uselocal(the assertion lands in its code, like thetrace-hookssymbols).
slot_timeout: MS (node and pool; milliseconds ≥ 1) overrides the node’s
pre-spawn wait bound for its executor: slot and resources: gates (default
100 ms — sized for “provided before start()”). Raise it for consumers of a
provider node — a first-in-topo node whose worker builds the resources
at runtime and provide()s them (the graph-native hw_init): size the
timeout to the provider’s async build time and the gate wait becomes a
rendezvous instead of a Busy. See the README’s provider-node recipe.
A graph holds at most 256 node slots (including pool members): all graph
indices are u8, and the macro rejects a larger declaration at expansion.
Nodes, pools, and individual deps may carry #[cfg(...)] attributes. A
proc-macro can’t evaluate cfg, so the node array is a fixed-length
[Option<&TaskNode>; M] over all declared slots (each entry Some/None via a
cfg-expression), the dep table is cfg-aware per-dep, and the order runs through
topo_sort_const at const-eval (after cfg). Absent nodes are skipped at runtime.
Generated items (at the call site): one pub static per node, a [TaskNode; K]
array + spawn_<pool> glue fn + <POOL>_POOL ElasticPool + the structural
pub consts <POOL>_MIN / <POOL>_MAX / <POOL>_MEMBERS (usize; for
const-context sizing downstream — a const cannot read them off the member
static) per pool, one slot pub static per resources: entry (shared
entries: one per unique name; plus, iff any entry is local, the
__SvLocalResourceSlot type), plus a
single pub static GRAPH: Graph<M> bundling the node slots, the dependency table,
the topological order, and (with the pool feature) the pools — pass &GRAPH to
Supervisor::new. The backing tables are private; read them through GRAPH.nodes
/ GRAPH.deps / GRAPH.order / GRAPH.pools (node count is GRAPH.nodes.len()).
With the supervisor’s trace feature (forwarded here) the generated spawn glue
also captures each SpawnToken’s task id into its node (set_task_id); with
metadata-names it stamps the node name into the task Metadata. These are
independent: metadata-names without trace emits a name-only spawn path
(stamp_name, no id capture, no _embassy_trace_* dependency), so node names
reach external tooling (rtos-trace/SystemView) without the trace recorders.
With trace-hooks the macro additionally defines the seven _embassy_trace_*
hook symbols at the declaration site (the supervisor crate is
forbid(unsafe_code) and cannot), forwarding to the supervisor’s trace
recorders — requires an edition-2024 consumer, and exactly one graph declaration
(or hook set) per binary.
Types are referenced absolutely (::embassy_supervisor::…), so the consuming
crate must depend on embassy-supervisor under its real name (not aliased).
Macros§
- supervisor_
graph - Declare a supervised task graph; see the crate docs for the surface syntax.