Expand description
§embassy-supervisor — a task-lifecycle supervisor for embassy
Application- and HAL-agnostic primitives for orchestrating a set of embassy tasks: bringing them up in dependency order, tearing them down in reverse, scaling an elastic worker pool with load, placing nodes on interrupt-priority tiers or a second core, and starting/stopping/pausing/resuming individual tasks at runtime while keeping the dependency graph consistent. The supervisor orchestrates task lifecycle and leaves the rest — allocation, HAL, power, what the tasks do — to the application.
§The model
- The graph is declared once with the
supervisor_graph!macro: each managed task becomes aTaskNodestatic, and the macro bundles the node slots, dependency table, and a topological order computed at compile time into a singleGraph(GRAPH). The whole graph is validated at compile time — a dependency cycle, an unknown or duplicate dependency, a duplicate name, or bad pool bounds are compile errors. Supervisor::newtakes&GRAPH(no work, no failure) and uses the order to bring tasks up in dependency order (Supervisor::start) and tear them down in reverse (Supervisor::teardown).executor NAME;items declare runtime-filledSpawnerSlots, andexecutor: NAMEon a node (or a whole pool) routes its spawn through one — an interrupt-priority tier or the second core. Bring-up awaits the slot (bounded), so an executor that comes up late — or on another core — is a rendezvous, not a race.- Each managed task names its worker with either
task:(preferred) — a plainasync fnthat the macro wraps in a generated#[embassy_executor::task]shell (one concrete shell per declaration, so a generic worker is fine) — orspawn:, naming a hand-written#[embassy_executor::task]directly. Atask:pool emits one shell sized to its members;pool_size: Nsizes a single node’s shell. resources: [NAME: Type, ..]on atask:node threads owned resources frommaininto the worker through macro-emittedResourceSlots — compile-time exclusive ownership (thePeripheralsfield is consumed, nosteal()inside the task), fail-closed provisioning (an unprovided slot failsstartwithSpawnError::Busy), and restore-on-exit so a respawn re-takes the same instance. Per-entry kind markers refine that default:consumehands the worker the value by value with no restore (drop-at-teardown drivers; rebuilt-per-cycle resources — a respawn fail-closes until the app re-provide()s);sharedis a fan-out slot for aCopyhandle (the glue copies viaResourceSlot::get, the slot stays filled — any number of nodes and whole pools may declare the same name); andlocalswaps in a graph-site slot without theT: Sendbound (!Senddriver handles, single-core contract) — it makes the macro emit anunsafe impl Syncinto the consuming crate, so it requires the non-defaultlocal-resourcesfeature. See the macro docs for the markers’ fine print.- The pre-spawn waits are per-node tunable (
slot_timeout:/TaskNode::with_slot_timeout), which makes provider nodes work: a first-in-topo node whose worker builds resources at runtime andprovide()s them into other nodes’ slots (the graph-nativehw_init); consumers size their timeout to the build and the gate wait becomes a rendezvous. - Two flags span every lifecycle operation: disabled (stopped until an
explicit
Activate— declareddisabledin the graph or control-stopped; seeTaskNode::set_disabled) and detached (self-managed: afterTaskNode::set_detachedno supervisor operation touches the node). - Each node carries a
TaskHandleof per-node atomic flags and single-consumerSignals. Every node is single-instance — no counts, no fan-out. SeeTaskHandle.
§Three lifecycles, distinguished by Mode
Mode::Terminate— the task exits its loop on shutdown and is respawned on the next bring-up. Stateless services (a network listener, a logger).Mode::Pause— the task acks the shutdown then parks onwait_resume(); it is resumed in place, never respawned. Tasks that retain a resource across the pause (an open peripheral handle, a socket).Mode::OnDemand— likeTerminate, but not started at boot and not auto-respawned; the supervisor brings it up and down at runtime to scale an elastic worker pool (ElasticPool) with load.
§Writing a supervised task
A supervised worker’s first parameter is its node. With task: you write a
plain async fn and the macro stamps the #[embassy_executor::task] shell
(and, with resources:, hands it &mut resource handles after the node, in
declared order); with spawn: you write the #[embassy_executor::task]
yourself. Either way the macro’s glue passes the node, and extra arguments come
from the partial-call spawn form. Four rules cover the task side of the protocol:
- race long-lived work against the stop request — that’s how a stop reaches
you.
TaskNode::run_cancellable_ackedis the everyday body (it owns theselectand acks for you;Err(Aborted)means a stop won),TaskNode::run_cancellablethe variant with cleanup between the two, andTaskNode::wait_shutdownthe raw signal when you write theselectyourself; - ack exactly once per stop with
TaskNode::ack_dropped: on exit (Terminate/OnDemand), or on each pause (Pause) before parking onTaskNode::wait_resume; - an autonomous exit calls
TaskNode::mark_exitedinstead — it acks and records completion, so the supervisor sees the node as down andTaskNode::has_exitedtells a body that returned on its own from one that was stopped (atask:shell does it for you); - resources follow the mode: a
Terminatetask re-acquires everything on respawn (drop-on-exit is the cleanup), aPausetask keeps what it holds across the park.
Pool workers additionally report load with TaskNode::mark_busy /
TaskNode::mark_idle (a real transition fires the scale signal itself), and
a self-managed daemon or run-once job opts out of supervision with
TaskNode::set_detached. The README’s Writing supervised tasks section has
per-mode skeletons.
§Beyond bring-up
Supervisor::runis bring-up plus the driver loop (pool scaling and the control mailbox) in one call; it returns only on aRunError, which the application escalates. Drive the pieces yourself when the loop must watch extra wake sources.- Every shutdown path is fallible, never a library panic:
Supervisor::teardownaborts at the first node that misses its ack and returns aShutdownTimeoutnaming it,Supervisor::teardown_continuepresses on through the rest and reports the first failure at the end (the “hardware reset next anyway” path). exit: Typeon a node adds a typed exit-value slot the application awaits withResourceSlot::wait_take— a run-once job hands its result back.state: Type = expr(featureheap-state) boxes per-activation state that is freed when the task exits, so a stopped subsystem costs no RAM.- Feature
readinessseparates spawned from serving: a task assertsset_ready()and adeps: [NET ready]edge makes bring-up (and pool growth) wait for it. Featurelivenessadds a per-node heartbeat (beat()/is_stale()) for alive-but-wedged detection. - A graph can span crates:
supervisor_fragment!declares a module’s nodes andcompose_graph!assembles the fragments into one graph.name: IDENT;gives a second graph in the same binary its own statics andSupervisor, for a subordinate sub-graph an application starts and tears down as a unit.
§What the supervisor does not do
- It does not model any power-state transition (sleep/wake): it reacts to “teardown” and “bring-up” requests; the application drives them.
- It does not allocate, and does no work at construction: the topological
sort runs at compile time (see the
supervisor_graph!macro). - It does not observe task internals. Tasks self-report their drop state via
ack_dropped()/mark_exited(); a task that misses the ack window comes back as aShutdownTimeoutnaming the node, for the application to act on. - It does not catch panics: a panicking task is not captured or restarted.
Pair the supervisor with a hardware watchdog for crashes, and the
livenessheartbeat for tasks that are alive but wedged.
§Cargo features
control(default) — the runtime control plane:ControlOp,request_control,Supervisor::apply_control.pool(default) — elastic worker pools:ElasticPool,Supervisor::run_pools, and thepoolsfield ofGraph.macros(default) — thesupervisor_graph!graph-declaration macro (andsupervisor_fragment!/compose_graph!).local-resources— permit thelocalresource kind; ⚠ opting in to the macro emitting a documentedunsafe impl Syncinto your crate.readiness—set_ready/clear_ready/wait_readyplus thereadydep marker, gating bring-up and pool growth on serving, not merely spawned.liveness— a per-node heartbeat:beat(),ticks_since_beat(),is_stale(max_age). A fresh spawn counts as a beat.heap-state— thestate: Type = exprclause: per-activation boxed state, reclaimed on task exit; ⚠ emits a smallunsafefallible-boxing helper and needs a#[global_allocator].defmt— route the supervisor’s logs throughdefmt; without it the log macros are no-ops.tracefamily (all opt-in) —trace: thetracemodule’s recorders consuming embassy-executor’s_embassy_trace_*hooks;trace-hooks:supervisor_graph!also defines the hook symbols;metadata-names: node names stamped into task Metadata for external consumers (rtos-trace/ SystemView) — independent oftrace, so it needs no hook symbols and pairs with embassy’s ownrtos-trace;trace-names: shorthand fortrace+metadata-names;trace-nested: preemption-exact accounting (a nested higher-tier poll credits its time back to the window it interrupted).
Build with default-features = false for a minimal core that only does
dependency-ordered bring-up/teardown (drops the control plane and pools,
trimming flash and a couple of statics).
§Example
supervisor_graph! declares the whole graph once — it generates the node
statics and a single Graph value GRAPH bundling the node slots, dep
table, and compile-time topological order (a dependency cycle is a compile
error), which Supervisor::new consumes.
use embassy_executor::Spawner;
use embassy_supervisor::{supervisor_graph, RunError, Supervisor, TaskNode};
// `app` depends on `net`; `task:` names a plain async worker fn the macro wraps
// in its `#[embassy_executor::task]` shell (`spawn:` takes one you wrote yourself).
supervisor_graph! {
node NET = Terminate, deps: [], task: net_task;
node APP = Terminate, deps: [NET], task: app_task;
}
// Plain async fns taking the node first — no embassy attribute needed. The
// combinator owns the shutdown `select` and the ack.
async fn net_task(node: &'static TaskNode) {
let _ = node.run_cancellable_acked(async { /* serve forever */ }).await;
}
async fn app_task(node: &'static TaskNode) {
let _ = node.run_cancellable_acked(async { /* serve forever */ }).await;
}
#[embassy_executor::task]
async fn supervisor_task(spawner: Spawner) {
// Infallible: the order is precomputed, so a dependency cycle is a compile error.
let sup = Supervisor::new(&GRAPH);
// Brings up `net`, then `app`, then drives pool scaling and runtime control
// requests (start/stop/pause/resume, applied in dependency order) forever;
// returns only on error, which the application escalates — typically a panic
// into a hardware-watchdog reset.
match sup.run(spawner).await {
RunError::Spawn(_) => panic!("bring-up failed"),
RunError::Shutdown(e) => panic!("{} missed its shutdown ack", e.node.name),
}
// Call the pieces yourself (`start`, then a `select(run_pools, wait_control)`
// loop) when the driver must watch extra wake sources.
}The firmware crate in the repository
is a complete working example (USB-net, an HTTP control plane, an elastic pool,
and OTA).
Modules§
- trace
- Trace-hook observability (feature
trace): a batteries-included consumer for embassy-executor’s_embassy_trace_*instrumentation hooks.
Macros§
- compose_
graph - Assemble one graph from
supervisor_fragment!relays plus compose-site items: - supervisor_
fragment - Declare a graph fragment:
supervisor_fragment! { name: NET_FRAG; <items> }emits a#[macro_export] macro_rules! NET_FRAGrelay that forwards the items (verbatim, wrapped in@fragment/@endfragmentattribution markers) into the singlesupervisor_graph!expansion acompose_graph!call site assembles — so every whole-graph compile-time pass (name map, u8 slot indices, topo order, shared-slot dedup, the 256 cap) still sees ALL items, across crates. - supervisor_
graph - Declare a supervised task graph and compute its topological order at compile
time (single source of nodes, deps, pool, and order). See the
embassy-supervisor-macroscrate for the surface syntax. Declare a supervised task graph; see the crate docs for the surface syntax.
Structs§
- Aborted
- The shutdown side of
TaskNode::run_cancellable’s result: the raced work future was cancelled at its await point because a stop/pause request won the select. Pairs naturally with theexit:slot — a worker returningResult<R, Aborted>records completed-vs-cancelled for whoever reads the exit value. - Control
Command - A runtime control request: drive
node(and, per the dependency graph and pool membership, the nodes it implies) in theopdirection. - Control
Queue Full - The control mailbox was full (4 outstanding requests) and the request was
not enqueued. Returned by
try_request_control; retry after the supervisor’s driver loop has drained a command, or use the awaitingrequest_controlfrom async contexts. - Deferred
Shrink - Grow immediately (stay responsive), but shrink only after the idle surplus has
persisted for
cooldown— damps grow→shrink→grow flapping. Holds its pending shrink deadline in aCell<Option<Instant>>(interior mutability under&self, since the pool is astatic);None= no shrink pending. - Elastic
Pool - An elastic pool of single-instance nodes scaled by policy
P. - Graph
- The compile-time task graph produced by
supervisor_graph!: the node slots, the dependency-index table, the topological order, and the elastic pools — the single valueSupervisor::newconsumes. The macro emits onepub static GRAPHof this type. The fields are public so the application can read them directly (e.g. a status endpoint iteratingGRAPH.nodes/GRAPH.deps). - Pool
Stats - Aggregate state of a pool, handed to the policy.
- Resource
Slot - A one-value handoff cell threading an owned resource from
maininto a supervised task — the safe replacement forPeripherals::steal()inside the task body. - Shutdown
Timeout - A node failed to ack a requested shutdown within
SHUTDOWN_ACK_TIMEOUT_MS. Returned bySupervisor::stop_node,Supervisor::teardown,Supervisor::teardown_continueand (featurecontrol)Supervisor::apply_control. The node is still marked running; the sane escalations are app-level — a hardware watchdog reset,panic!, or a retry. - Spawner
Slot - A runtime-filled slot holding the
SendSpawnerof an executor other than the one the supervisor runs on — anInterruptExecutortier, the second core’s executor, any foreign thread executor (viaSpawner::make_send()). - Supervisor
- Orchestrates a set of managed tasks across spawn / teardown / bring-up.
- Task
Handle - Coordination state for one task. Embedded inside
TaskNode. - Task
Node - A node in the supervisor’s task graph.
Enums§
- Control
Op - Which way to drive a node. Higher-level verbs fold onto these two:
start/resume→Activate,stop/pause→Deactivate. The concrete mechanism (respawn vs resume vs leave-to-pool) is then chosen per nodeModeby the supervisor when it applies the command (Supervisor::apply_control). - Mode
- Lifecycle policy for a managed task: what the task does on shutdown and what the supervisor does to bring it back.
- Pool
Action - What the supervisor should do for a pool this tick. The async part (start /
stop) is applied by the caller, keeping
Poolobject-safe without futures. - RunError
- Why
Supervisor::runstopped — it only returns on error, and every arm is an app-level escalation (typicallypanic!into a hardware-watchdog reset). - Scale
Action - What a policy wants done this evaluation.
Traits§
- Pool
- Object-safe, synchronous pool interface so
&dyn Poolneeds no heap: the policy decides here; the supervisor performs the async start/stop. - Resource
Gate - Type-erased readiness view of a
ResourceSlot, for the supervisor’s bring-up wait. - Scaling
Policy - Swappable scaling decision.
decideis synchronous; stateful policies use interior mutability (the pool is astatic, so&self).
Functions§
- request_
control - Enqueue a control request, waiting for mailbox capacity if it is full. Lossless — the request is delivered once the supervisor’s driver loop drains an earlier command. Called by the application’s control surface.
- request_
scale - Fire the scale-request signal. Called by a task on a busy/idle transition.
A no-op when the
poolfeature is disabled (no pools to re-evaluate). - try_
request_ control - Non-blocking variant of
request_controlfor sync contexts (ISRs, callbacks). Fails withControlQueueFullinstead of dropping the request when the mailbox is full — the caller decides whether to retry or surface it. - wait_
control - Await the next control request. Selected by the supervisor’s driver loop against pool scaling and any other application wake sources.
- wait_
scale - Await the next scale request. The supervisor’s driver loop selects this against its other wake sources and runs the scaling policy on each wake.