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:
- select long-lived work against
TaskNode::wait_shutdown— that’s how a stop reaches you; - 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 acks too, so the supervisor sees the node as down;
- 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.
§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(); a task that fails to ack within a timeout panics the supervisor with the offending node’s name.
§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.defmt— route the supervisor’s logs throughdefmt; without it the log macros are no-ops.tracefamily (all opt-in) —trace: thetracerecorders 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, Supervisor, wait_control};
// `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;
}
#[embassy_executor::task]
async fn supervisor_task(spawner: Spawner) {
let sup = Supervisor::new(&GRAPH);
sup.start(spawner).await.expect("initial spawn"); // brings up `net`, then `app`
loop {
// Apply runtime start/stop/pause/resume requests in dependency order.
let cmd = wait_control().await;
sup.apply_control(cmd, spawner).await;
}
// With the `pool` feature you'd instead drive scaling and control together:
// `select(sup.run_pools(spawner), wait_control())` (see the `firmware` crate).
}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§
- 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§
- Control
Command - A runtime control request: drive
node(and, per the dependency graph and pool membership, the nodes it implies) in theopdirection. - 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. - 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. - 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. Non-blocking; drops if the mailbox is full (4 outstanding), which is harmless for low-frequency manual control. 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). - 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.