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. select long-lived work against TaskNode::wait_shutdown — that’s how a stop reaches you;
  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 acks too, so the supervisor sees the node as down;
  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.

§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 the pools field of Graph.
  • defmt — route the supervisor’s logs through defmt; without it the log macros are no-ops.
  • trace family (all opt-in) — trace: the trace 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, 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-macros crate for the surface syntax. Declare a supervised task graph; see the crate docs for the surface syntax.

Structs§

ControlCommand
A runtime control request: drive node (and, per the dependency graph and pool membership, the nodes it implies) in the op direction.
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.
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.
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. 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 pool feature 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.