Skip to main content

Crate embassy_supervisor

Crate embassy_supervisor 

Source
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 a TaskNode static, and the macro bundles the node slots, dependency table, and a topological order computed at compile time into a single Graph (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::new takes &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-filled SpawnerSlots, and executor: NAME on 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 plain async fn that the macro wraps in a generated #[embassy_executor::task] shell (one concrete shell per declaration, so a generic worker is fine) — or spawn:, naming a hand-written #[embassy_executor::task] directly. A task: pool emits one shell sized to its members; pool_size: N sizes a single node’s shell.
  • resources: [NAME: Type, ..] on a task: node threads owned resources from main into the worker through macro-emitted ResourceSlots — compile-time exclusive ownership (the Peripherals field is consumed, no steal() inside the task), fail-closed provisioning (an unprovided slot fails start with SpawnError::Busy), and restore-on-exit so a respawn re-takes the same instance. Per-entry kind markers refine that default: consume hands 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); shared is a fan-out slot for a Copy handle (the glue copies via ResourceSlot::get, the slot stays filled — any number of nodes and whole pools may declare the same name); and local swaps in a graph-site slot without the T: Send bound (!Send driver handles, single-core contract) — it makes the macro emit an unsafe impl Sync into the consuming crate, so it requires the non-default local-resources feature. 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 and provide()s them into other nodes’ slots (the graph-native hw_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 — declared disabled in the graph or control-stopped; see TaskNode::set_disabled) and detached (self-managed: after TaskNode::set_detached no supervisor operation touches the node).
  • Each node carries a TaskHandle of per-node atomic flags and single-consumer Signals. Every node is single-instance — no counts, no fan-out. See TaskHandle.

§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 on wait_resume(); it is resumed in place, never respawned. Tasks that retain a resource across the pause (an open peripheral handle, a socket).
  • Mode::OnDemand — like Terminate, 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:

  1. race long-lived work against the stop request — that’s how a stop reaches you. TaskNode::run_cancellable_acked is the everyday body (it owns the select and acks for you; Err(Aborted) means a stop won), TaskNode::run_cancellable the variant with cleanup between the two, and TaskNode::wait_shutdown the raw signal when you write the select yourself;
  2. ack exactly once per stop with TaskNode::ack_dropped: on exit (Terminate/OnDemand), or on each pause (Pause) before parking on TaskNode::wait_resume;
  3. an autonomous exit calls TaskNode::mark_exited instead — it acks and records completion, so the supervisor sees the node as down and TaskNode::has_exited tells a body that returned on its own from one that was stopped (a task: shell does it for you);
  4. resources follow the mode: a Terminate task re-acquires everything on respawn (drop-on-exit is the cleanup), a Pause task 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::run is bring-up plus the driver loop (pool scaling and the control mailbox) in one call; it returns only on a RunError, 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::teardown aborts at the first node that misses its ack and returns a ShutdownTimeout naming it, Supervisor::teardown_continue presses on through the rest and reports the first failure at the end (the “hardware reset next anyway” path).
  • exit: Type on a node adds a typed exit-value slot the application awaits with ResourceSlot::wait_take — a run-once job hands its result back. state: Type = expr (feature heap-state) boxes per-activation state that is freed when the task exits, so a stopped subsystem costs no RAM.
  • Feature readiness separates spawned from serving: a task asserts set_ready() and a deps: [NET ready] edge makes bring-up (and pool growth) wait for it. Feature liveness adds a per-node heartbeat (beat() / is_stale()) for alive-but-wedged detection.
  • A graph can span crates: supervisor_fragment! declares a module’s nodes and compose_graph! assembles the fragments into one graph. name: IDENT; gives a second graph in the same binary its own statics and Supervisor, 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 a ShutdownTimeout naming 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 liveness heartbeat 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 the pools field of Graph.
  • macros (default) — the supervisor_graph! graph-declaration macro (and supervisor_fragment! / compose_graph!).
  • local-resources — permit the local resource kind; ⚠ opting in to the macro emitting a documented unsafe impl Sync into your crate.
  • readinessset_ready/clear_ready/wait_ready plus the ready dep 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 — the state: Type = expr clause: per-activation boxed state, reclaimed on task exit; ⚠ emits a small unsafe fallible-boxing helper and needs a #[global_allocator].
  • defmt — route the supervisor’s logs through defmt; without it the log macros are no-ops.
  • trace family (all opt-in) — trace: the trace module’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 of trace, so it needs no hook symbols and pairs with embassy’s own rtos-trace; trace-names: shorthand for trace + 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_FRAG relay that forwards the items (verbatim, wrapped in @fragment/@endfragment attribution markers) into the single supervisor_graph! expansion a compose_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-macros crate 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 the exit: slot — a worker returning Result<R, Aborted> records completed-vs-cancelled for whoever reads the exit value.
ControlCommand
A runtime control request: drive node (and, per the dependency graph and pool membership, the nodes it implies) in the op direction.
ControlQueueFull
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 awaiting request_control from async contexts.
DeferredShrink
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 a Cell<Option<Instant>> (interior mutability under &self, since the pool is a static); None = no shrink pending.
ElasticPool
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 value Supervisor::new consumes. The macro emits one pub static GRAPH of this type. The fields are public so the application can read them directly (e.g. a status endpoint iterating GRAPH.nodes / GRAPH.deps).
PoolStats
Aggregate state of a pool, handed to the policy.
ResourceSlot
A one-value handoff cell threading an owned resource from main into a supervised task — the safe replacement for Peripherals::steal() inside the task body.
ShutdownTimeout
A node failed to ack a requested shutdown within SHUTDOWN_ACK_TIMEOUT_MS. Returned by Supervisor::stop_node, Supervisor::teardown, Supervisor::teardown_continue and (feature control) Supervisor::apply_control. The node is still marked running; the sane escalations are app-level — a hardware watchdog reset, panic!, or a retry.
SpawnerSlot
A runtime-filled slot holding the SendSpawner of an executor other than the one the supervisor runs on — an InterruptExecutor tier, the second core’s executor, any foreign thread executor (via Spawner::make_send()).
Supervisor
Orchestrates a set of managed tasks across spawn / teardown / bring-up.
TaskHandle
Coordination state for one task. Embedded inside TaskNode.
TaskNode
A node in the supervisor’s task graph.

Enums§

ControlOp
Which way to drive a node. Higher-level verbs fold onto these two: start/resumeActivate, stop/pauseDeactivate. The concrete mechanism (respawn vs resume vs leave-to-pool) is then chosen per node Mode by 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.
PoolAction
What the supervisor should do for a pool this tick. The async part (start / stop) is applied by the caller, keeping Pool object-safe without futures.
RunError
Why Supervisor::run stopped — it only returns on error, and every arm is an app-level escalation (typically panic! into a hardware-watchdog reset).
ScaleAction
What a policy wants done this evaluation.

Traits§

Pool
Object-safe, synchronous pool interface so &dyn Pool needs no heap: the policy decides here; the supervisor performs the async start/stop.
ResourceGate
Type-erased readiness view of a ResourceSlot, for the supervisor’s bring-up wait.
ScalingPolicy
Swappable scaling decision. decide is synchronous; stateful policies use interior mutability (the pool is a static, 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 pool feature is disabled (no pools to re-evaluate).
try_request_control
Non-blocking variant of request_control for sync contexts (ISRs, callbacks). Fails with ControlQueueFull instead 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.