embassy_supervisor/lib.rs
1// `no_std` for the shipped crate and the embedded build; under `cargo test` the
2// crate is built for the host, where the test harness and the unit tests need `std`.
3#![cfg_attr(not(test), no_std)]
4#![forbid(unsafe_code)]
5#![deny(missing_docs)]
6//! # embassy-supervisor — a task-lifecycle supervisor for [embassy](https://embassy.dev)
7//!
8//! Application- and HAL-agnostic primitives for orchestrating a set of embassy
9//! tasks: bringing them up in dependency order, tearing them down in reverse,
10//! scaling an elastic worker pool with load, placing nodes on interrupt-priority
11//! tiers or a second core, and starting/stopping/pausing/resuming individual
12//! tasks at runtime while keeping the dependency graph consistent. The supervisor
13//! orchestrates task *lifecycle* and leaves the rest — allocation, HAL, power,
14//! what the tasks do — to the application.
15//!
16//! ## The model
17//!
18//! * The graph is declared once with the [`supervisor_graph!`] macro: each
19//! managed task becomes a [`TaskNode`] `static`, and the macro bundles the node
20//! slots, dependency table, and a topological order computed **at compile time**
21//! into a single [`Graph`] (`GRAPH`). The whole graph is validated at compile
22//! time — a dependency cycle, an unknown or duplicate dependency, a duplicate
23//! name, or bad pool bounds are compile errors.
24//! * [`Supervisor::new`] takes `&GRAPH` (no work, no failure) and uses the order
25//! to bring tasks up in dependency order ([`Supervisor::start`]) and tear them
26//! down in reverse ([`Supervisor::teardown`]).
27//! * `executor NAME;` items declare runtime-filled [`SpawnerSlot`]s, and
28//! `executor: NAME` on a node (or a whole pool) routes its spawn through one —
29//! an interrupt-priority tier or the second core. Bring-up *awaits* the slot
30//! (bounded), so an executor that comes up late — or on another core — is a
31//! rendezvous, not a race.
32//! * Each managed task names its worker with either `task:` (preferred) — a
33//! **plain `async fn`** that the macro wraps in a generated
34//! `#[embassy_executor::task]` shell (one concrete shell per declaration, so a
35//! *generic* worker is fine) — or `spawn:`, naming a hand-written
36//! `#[embassy_executor::task]` directly. A `task:` pool emits one shell sized to
37//! its members; `pool_size: N` sizes a single node's shell.
38//! * `resources: [NAME: Type, ..]` on a `task:` node threads **owned resources
39//! from `main`** into the worker through macro-emitted [`ResourceSlot`]s —
40//! compile-time exclusive ownership (the `Peripherals` field is consumed, no
41//! `steal()` inside the task), fail-closed provisioning (an unprovided slot
42//! fails `start` with `SpawnError::Busy`), and restore-on-exit so a respawn
43//! re-takes the *same instance*.
44//! * Two flags span every lifecycle operation: **disabled** (stopped until an
45//! explicit `Activate` — declared `disabled` in the graph or control-stopped;
46//! see [`TaskNode::set_disabled`]) and **detached** (self-managed: after
47//! [`TaskNode::set_detached`] no supervisor operation touches the node).
48//! * Each node carries a `TaskHandle` of per-node atomic flags and
49//! single-consumer `Signal`s. Every node is single-instance — no counts, no
50//! fan-out. See [`TaskHandle`].
51//!
52//! ## Three lifecycles, distinguished by [`Mode`]
53//!
54//! * [`Mode::Terminate`] — the task exits its loop on shutdown and is respawned
55//! on the next bring-up. Stateless services (a network listener, a logger).
56//! * [`Mode::Pause`] — the task acks the shutdown then parks on
57//! `wait_resume()`; it is resumed in place, never respawned. Tasks that
58//! retain a resource across the pause (an open peripheral handle, a socket).
59//! * [`Mode::OnDemand`] — like `Terminate`, but not started at boot and not
60//! auto-respawned; the supervisor brings it up and down at runtime to scale
61//! an elastic worker pool ([`ElasticPool`]) with load.
62//!
63//! ## Writing a supervised task
64//!
65//! A supervised worker's first parameter is its node. With `task:` you write a
66//! plain `async fn` and the macro stamps the `#[embassy_executor::task]` shell
67//! (and, with `resources:`, hands it `&mut` resource handles after the node, in
68//! declared order); with `spawn:` you write the `#[embassy_executor::task]`
69//! yourself. Either way the macro's glue passes the node, and extra arguments come
70//! from the partial-call spawn form. Four rules cover the task side of the protocol:
71//!
72//! 1. select long-lived work against [`TaskNode::wait_shutdown`] — that's how a
73//! stop reaches you;
74//! 2. ack exactly once per stop with [`TaskNode::ack_dropped`]: on exit
75//! (`Terminate`/`OnDemand`), or on each pause (`Pause`) *before* parking on
76//! [`TaskNode::wait_resume`];
77//! 3. an autonomous exit acks too, so the supervisor sees the node as down;
78//! 4. resources follow the mode: a `Terminate` task re-acquires everything on
79//! respawn (drop-on-exit is the cleanup), a `Pause` task keeps what it holds
80//! across the park.
81//!
82//! Pool workers additionally report load with [`TaskNode::mark_busy`] /
83//! [`TaskNode::mark_idle`] (a real transition fires the scale signal itself), and
84//! a self-managed daemon or run-once job opts out of supervision with
85//! [`TaskNode::set_detached`]. The README's *Writing supervised tasks* section has
86//! per-mode skeletons.
87//!
88//! ## What the supervisor does *not* do
89//!
90//! * It does not model any power-state transition (sleep/wake): it reacts to
91//! "teardown" and "bring-up" requests; the application drives them.
92//! * It does not allocate, and does no work at construction: the topological
93//! sort runs at compile time (see the `supervisor_graph!` macro).
94//! * It does not observe task internals. Tasks self-report their drop state via
95//! `ack_dropped()`; a task that fails to ack within a timeout panics the
96//! supervisor with the offending node's name.
97//!
98//! ## Cargo features
99//!
100//! * `control` *(default)* — the runtime control plane: [`ControlOp`],
101//! [`request_control`], [`Supervisor::apply_control`].
102//! * `pool` *(default)* — elastic worker pools: [`ElasticPool`],
103//! [`Supervisor::run_pools`], and the `pools` field of [`Graph`].
104//! * `defmt` — route the supervisor's logs through `defmt`; without it the log
105//! macros are no-ops.
106//! * `trace` family (all opt-in) — `trace`: the [`trace`] recorders consuming
107//! embassy-executor's `_embassy_trace_*` hooks; `trace-hooks`:
108//! `supervisor_graph!` also *defines* the hook symbols; `metadata-names`: node
109//! names stamped into task Metadata for external consumers (rtos-trace/
110//! SystemView) — independent of `trace`, so it needs no hook symbols and pairs
111//! with embassy's own `rtos-trace`; `trace-names`: shorthand for `trace` +
112//! `metadata-names`; `trace-nested`: preemption-exact accounting (a nested
113//! higher-tier poll credits its time back to the window it interrupted).
114//!
115//! Build with `default-features = false` for a minimal core that only does
116//! dependency-ordered bring-up/teardown (drops the control plane and pools,
117//! trimming flash and a couple of statics).
118//!
119//! ## Example
120//!
121//! [`supervisor_graph!`] declares the whole graph once — it generates the node
122//! `static`s and a single [`Graph`] value `GRAPH` bundling the node slots, dep
123//! table, and compile-time topological order (a dependency cycle is a compile
124//! error), which [`Supervisor::new`] consumes.
125//!
126//! ```ignore
127//! use embassy_executor::Spawner;
128//! use embassy_supervisor::{supervisor_graph, Supervisor, wait_control};
129//!
130//! // `app` depends on `net`; `task:` names a plain async worker fn the macro wraps
131//! // in its `#[embassy_executor::task]` shell (`spawn:` takes one you wrote yourself).
132//! supervisor_graph! {
133//! node NET = Terminate, deps: [], task: net_task;
134//! node APP = Terminate, deps: [NET], task: app_task;
135//! }
136//!
137//! #[embassy_executor::task]
138//! async fn supervisor_task(spawner: Spawner) {
139//! let sup = Supervisor::new(&GRAPH);
140//! sup.start(spawner).await.expect("initial spawn"); // brings up `net`, then `app`
141//! loop {
142//! // Apply runtime start/stop/pause/resume requests in dependency order.
143//! let cmd = wait_control().await;
144//! sup.apply_control(cmd, spawner).await;
145//! }
146//! // With the `pool` feature you'd instead drive scaling and control together:
147//! // `select(sup.run_pools(spawner), wait_control())` (see the `firmware` crate).
148//! }
149//! ```
150//!
151//! The `firmware` crate in the [repository](https://github.com/cedrivard/embassy-supervisor)
152//! is a complete working example (USB-net, an HTTP control plane, an elastic pool,
153//! and OTA).
154
155#[macro_use]
156mod fmt;
157
158use core::cell::Cell;
159use core::sync::atomic::Ordering;
160
161use embassy_executor::{SendSpawner, SpawnError, Spawner};
162use embassy_futures::select::{Either, select};
163use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
164use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
165#[cfg(feature = "control")]
166use embassy_sync::channel::Channel;
167use embassy_sync::signal::Signal;
168use embassy_time::{Timer, with_timeout};
169use portable_atomic::AtomicBool;
170#[cfg(feature = "trace")]
171use portable_atomic::AtomicU32;
172
173// ─── Scale-request signal (task → supervisor) ──────────────────────────────
174//
175// Elastic pool workers fire this when their busy/idle status changes; the
176// supervisor's `run_pools` loop awaits it and re-runs the pool policies
177// (`ElasticPool`). Single-consumer `Signal`: many tasks may `signal()`, only the
178// supervisor `wait()`s. This is the *only* path by which task status reaches the
179// supervisor — it never polls.
180#[cfg(feature = "pool")]
181static SCALE_REQ: Signal<CriticalSectionRawMutex, ()> = Signal::new();
182
183/// Fire the scale-request signal. Called by a task on a busy/idle transition.
184/// A no-op when the `pool` feature is disabled (no pools to re-evaluate).
185pub fn request_scale() {
186 #[cfg(feature = "pool")]
187 SCALE_REQ.signal(());
188}
189
190/// Await the next scale request. The supervisor's driver loop selects this
191/// against its other wake sources and runs the scaling policy on each wake.
192#[cfg(feature = "pool")]
193pub async fn wait_scale() {
194 SCALE_REQ.wait().await;
195}
196
197// ─── Runtime control commands (app → supervisor) ───────────────────────────
198//
199// An application's control surface (e.g. a network endpoint) usually can't drive
200// the supervisor directly: the `Supervisor` and the `Spawner` live on the
201// supervisor task's stack, not in a `static`. So control is decoupled via this
202// channel — the caller `request_control()`s a (node, op) pair and returns
203// immediately; the supervisor's driver loop `wait_control()`s it and runs the
204// dependency-honoring `apply_control`. A `Channel` (not a `Signal`) so
205// back-to-back requests aren't coalesced; capacity 4 is ample for hand-driven
206// control and a full channel simply drops the surplus (`try_send`).
207
208/// Which way to drive a node. Higher-level verbs fold onto these two:
209/// `start`/`resume` → `Activate`, `stop`/`pause` → `Deactivate`. The concrete
210/// mechanism (respawn vs resume vs leave-to-pool) is then chosen per node `Mode`
211/// by the supervisor when it applies the command ([`Supervisor::apply_control`]).
212#[cfg(feature = "control")]
213#[derive(Clone, Copy, PartialEq, Eq, Debug)]
214pub enum ControlOp {
215 /// Bring the node up (start a stopped `Terminate` node, resume a `Pause` node).
216 Activate,
217 /// Take the node down (and its dependents, per the graph).
218 Deactivate,
219}
220
221/// A runtime control request: drive `node` (and, per the dependency graph and
222/// pool membership, the nodes it implies) in the `op` direction.
223#[cfg(feature = "control")]
224#[derive(Clone, Copy, Debug)]
225pub struct ControlCommand {
226 /// The node to drive.
227 pub node: &'static TaskNode,
228 /// The direction to drive it.
229 pub op: ControlOp,
230}
231
232/// App → supervisor control mailbox. `&'static TaskNode` is `Copy + Sync`, so
233/// the target rides the channel directly — no name lookup needed supervisor-side.
234#[cfg(feature = "control")]
235static CONTROL_REQ: Channel<CriticalSectionRawMutex, ControlCommand, 4> = Channel::new();
236
237/// Enqueue a control request. Non-blocking; drops if the mailbox is full (4
238/// outstanding), which is harmless for low-frequency manual control. Called by
239/// the application's control surface.
240#[cfg(feature = "control")]
241pub fn request_control(node: &'static TaskNode, op: ControlOp) {
242 let _ = CONTROL_REQ.try_send(ControlCommand { node, op });
243}
244
245/// Await the next control request. Selected by the supervisor's driver loop
246/// against pool scaling and any other application wake sources.
247#[cfg(feature = "control")]
248pub async fn wait_control() -> ControlCommand {
249 CONTROL_REQ.receive().await
250}
251
252/// Per-node timeout for `wait_dropped`. A task that doesn't ack within this
253/// window is a bug (e.g. a missing `ack_dropped()` call) and panics the
254/// supervisor with the offending node's name. 2 s comfortably exceeds a typical
255/// task's poll period and peripheral settle time.
256const SHUTDOWN_ACK_TIMEOUT_MS: u64 = 2_000;
257
258/// How long the supervisor's bring-up waits for a node's `executor:`
259/// [`SpawnerSlot`] to be filled before failing the spawn with
260/// [`SpawnError::Busy`]. A genuine cross-core rendezvous resolves in microseconds;
261/// a slot empty this long is a misconfiguration (the app never registered that
262/// executor's spawner). Bounded, so a misconfigured graph fails loudly instead of
263/// hanging bring-up forever.
264const SLOT_READY_TIMEOUT: embassy_time::Duration = embassy_time::Duration::from_millis(100);
265
266// ─── Mode ────────────────────────────────────────────────────────────────
267
268/// Lifecycle policy for a managed task: what the task does on shutdown and what
269/// the supervisor does to bring it back.
270#[derive(Clone, Copy, PartialEq, Eq, Debug)]
271pub enum Mode {
272 /// Task exits its loop on shutdown. The supervisor respawns it via the
273 /// node's `spawn` fn from `respawn_terminate`.
274 Terminate,
275 /// Task acks shutdown and parks on `wait_resume()`. The supervisor resumes
276 /// it from `resume_pausable`; the task is never respawned, so it keeps any
277 /// resource it holds (a peripheral handle, a socket) across the pause.
278 Pause,
279 /// Like `Terminate` (exits on shutdown), but **not** started at boot and
280 /// **not** auto-respawned. The supervisor brings it up and down at runtime
281 /// via `start_node` / `stop_node` in response to load — see [`ElasticPool`].
282 /// `start()` skips it; `respawn_terminate()` leaves it down (it
283 /// re-grows under demand); `teardown()` only acts on it while it is running.
284 OnDemand,
285}
286
287impl Mode {
288 /// Stable lower-case wire name, used both for serialization (e.g. a JSON
289 /// task-state view) and for `defmt` logging — the single source of these
290 /// strings.
291 pub fn as_str(&self) -> &'static str {
292 match self {
293 Mode::Terminate => "terminate",
294 Mode::Pause => "pause",
295 Mode::OnDemand => "ondemand",
296 }
297 }
298}
299
300#[cfg(feature = "defmt")]
301impl defmt::Format for Mode {
302 fn format(&self, f: defmt::Formatter) {
303 defmt::write!(f, "{}", self.as_str());
304 }
305}
306
307// ─── TaskHandle ──────────────────────────────────────────────────────────
308
309/// Coordination state for one task. Embedded inside [`TaskNode`].
310///
311/// Every node is single-instance, so each field is a per-node atomic flag or a
312/// single-consumer signal — no counts, no fan-out. Written by one side (task or
313/// supervisor) and read by the other:
314/// * `shutdown` / `shutdown_wake` — supervisor requests exit; the task parks
315/// on the signal and reads the flag.
316/// * `dropped` / `dropped_wake` — the task acks its exit; the supervisor
317/// parks on the signal (with a timeout) and reads the flag.
318/// * `resume_wake` — supervisor resumes a parked Pause-mode task.
319/// * `running` — supervisor's record that the node is spawned; `busy` — the
320/// task's active/idle status. Both read by the elastic scaling policy.
321/// * `disabled` — the node has been manually deactivated; see below.
322pub struct TaskHandle {
323 /// Set true by the supervisor when shutdown is requested.
324 /// Cleared by `reset()` before the next spawn.
325 shutdown: AtomicBool,
326 /// Wake source for `wait_shutdown()`. Fired by `signal_shutdown()`.
327 shutdown_wake: Signal<CriticalSectionRawMutex, ()>,
328 /// Set true by the instance when it acks the shutdown (a bool, not a count,
329 /// since every node is single-instance). Cleared by `reset()`.
330 dropped: AtomicBool,
331 /// Wake source for `wait_dropped()`. Fired by `ack_dropped()`.
332 dropped_wake: Signal<CriticalSectionRawMutex, ()>,
333 /// True while the supervisor has the node spawned and it hasn't exited.
334 /// Always-on nodes are set true by `start()`; `OnDemand` nodes are set
335 /// true/false by `start_node()` / `stop_node()`. `teardown()` only acts on
336 /// `running` nodes, so a down `OnDemand` node doesn't stall it.
337 running: AtomicBool,
338 /// True while the task is actively serving (its active/idle status). Set by
339 /// `mark_busy()` / `mark_idle()`; read by the scaling policy.
340 busy: AtomicBool,
341 /// Wake source for `wait_resume()` on Pause-mode tasks. Fired by
342 /// `signal_resume()`.
343 resume_wake: Signal<CriticalSectionRawMutex, ()>,
344 /// True while the node has been manually deactivated (stopped/paused) via the
345 /// runtime control interface (`Supervisor::deactivate`). Unlike the other
346 /// flags this one is **lifecycle-spanning**: it is *not* cleared by
347 /// `reset()`, so a manual stop "sticks" — the automatic bring-up paths
348 /// (`start`, `respawn_terminate`, `resume_pausable`, and the elastic pool's
349 /// grow) skip a node while it is set. Cleared only by `Supervisor::activate`.
350 /// Because it lives in a `static`, it also survives a power-state transition
351 /// that retains RAM (e.g. a warm-resume from deep sleep).
352 disabled: AtomicBool,
353 /// Self-managed: while set, the supervisor never drives this node — teardown,
354 /// deactivate/activate, `stop_node`, respawn, and pause-resume all skip it. Not
355 /// cleared by `reset()`. Full rationale on [`TaskNode::set_detached`].
356 detached: AtomicBool,
357 /// The executor task id currently running this node (`TaskRef::id()`, captured
358 /// from the `SpawnToken` by the macro's spawn glue). `0` = unknown (not yet
359 /// spawned, or a parked/closure-spawned node that never registered). Overwritten
360 /// on every (re)spawn, so — unlike an external tracker — it stays correct across
361 /// respawns without any unlinking.
362 #[cfg(feature = "trace")]
363 task_id: AtomicU32,
364 /// Accumulated executor-poll time for this node, in embassy-time ticks,
365 /// wrapping. Consumers sample twice and `wrapping_sub` to get a rate; the
366 /// crate does no windowing.
367 #[cfg(feature = "trace")]
368 exec_ticks: AtomicU32,
369 /// Number of executor polls of this node, wrapping.
370 #[cfg(feature = "trace")]
371 polls: AtomicU32,
372 /// Longest single poll ever observed, in ticks — the "never yields" watermark.
373 /// A large value names the node that hogged the executor even after the fact,
374 /// which a live check cannot do from the blocked executor itself.
375 #[cfg(feature = "trace")]
376 max_poll_ticks: AtomicU32,
377}
378
379impl TaskHandle {
380 const fn new(disabled_at_boot: bool) -> Self {
381 Self {
382 shutdown: AtomicBool::new(false),
383 shutdown_wake: Signal::new(),
384 dropped: AtomicBool::new(false),
385 dropped_wake: Signal::new(),
386 running: AtomicBool::new(false),
387 busy: AtomicBool::new(false),
388 resume_wake: Signal::new(),
389 disabled: AtomicBool::new(disabled_at_boot),
390 detached: AtomicBool::new(false),
391 #[cfg(feature = "trace")]
392 task_id: AtomicU32::new(0),
393 #[cfg(feature = "trace")]
394 exec_ticks: AtomicU32::new(0),
395 #[cfg(feature = "trace")]
396 polls: AtomicU32::new(0),
397 #[cfg(feature = "trace")]
398 max_poll_ticks: AtomicU32::new(0),
399 }
400 }
401}
402
403// ─── Executor spawner slots ──────────────────────────────────────────────
404
405/// A runtime-filled slot holding the [`SendSpawner`] of an executor other than
406/// the one the supervisor runs on — an `InterruptExecutor` tier, the second
407/// core's executor, any foreign thread executor (via `Spawner::make_send()`).
408///
409/// Declared by the `executor NAME;` item of [`supervisor_graph!`]; nodes carrying
410/// `executor: NAME` are spawned through the slot instead of the supervisor's own
411/// `Spawner`. The application fills it once at startup — before, or concurrently
412/// with, [`Supervisor::start`] (e.g. from the second core's bring-up):
413///
414/// ```ignore
415/// static EXECUTOR_HIGH: InterruptExecutor = InterruptExecutor::new();
416/// HIGH.set(EXECUTOR_HIGH.start(interrupt::SWI_IRQ_0));
417/// sup.start(spawner).await?; // nodes declared `executor: HIGH` spawn on that tier
418/// ```
419///
420/// The supervisor's bring-up (`start` / `start_node` / `respawn_terminate`) awaits
421/// [`ready`](Self::ready) for a node's slot before spawning it, so a tier filled
422/// late — or from another core — is handled without a race; a slot still empty after
423/// the supervisor's bounded wait fails the spawn with [`SpawnError::Busy`] rather
424/// than silently dropping the task. Spawned futures must be `Send` (a non-`Send`
425/// `executor:` task is a compile error at the glue).
426pub struct SpawnerSlot {
427 slot: BlockingMutex<CriticalSectionRawMutex, Cell<Option<SendSpawner>>>,
428 /// Wakes a `ready()` waiter when `set` fills the slot (cross-core safe:
429 /// `Signal` is critical-section based and latches).
430 filled: Signal<CriticalSectionRawMutex, ()>,
431}
432
433impl SpawnerSlot {
434 /// An empty slot (`const` — it lives in a `static` the macro emits).
435 pub const fn new() -> Self {
436 Self {
437 slot: BlockingMutex::new(Cell::new(None)),
438 filled: Signal::new(),
439 }
440 }
441
442 /// Fill the slot (last set wins) and wake a [`ready`](Self::ready) waiter.
443 /// Call before [`Supervisor::start`] — or from the other core's bring-up,
444 /// with the supervisor awaiting `ready()`.
445 pub fn set(&self, spawner: SendSpawner) {
446 self.slot.lock(|c| c.set(Some(spawner)));
447 self.filled.signal(());
448 }
449
450 /// The registered spawner, or `None` while unfilled.
451 pub fn get(&self) -> Option<SendSpawner> {
452 self.slot.lock(Cell::get)
453 }
454
455 /// Await the slot and return the spawner. The rendezvous primitive: the
456 /// supervisor's bring-up awaits this for a node's `executor:` slot before
457 /// spawning it (bounded, see [`Supervisor::start`]), so a tier filled late — or
458 /// from another core — is handled without a race. Returns immediately once the
459 /// slot is filled, so any number of *late* callers are fine (an application can
460 /// gate work on the executor being up). While the slot is still empty, at most
461 /// one task should be parked here: the underlying `Signal` holds a single waker,
462 /// so a second pre-fill waiter would displace the first.
463 pub async fn ready(&self) -> SendSpawner {
464 loop {
465 if let Some(sp) = self.get() {
466 return sp;
467 }
468 // `Signal` latches: a `set()` racing between the check above and
469 // this wait still wakes us.
470 self.filled.wait().await;
471 }
472 }
473}
474
475impl Default for SpawnerSlot {
476 fn default() -> Self {
477 Self::new()
478 }
479}
480
481// ─── ResourceSlot ────────────────────────────────────────────────────────
482
483/// Type-erased readiness view of a [`ResourceSlot`], for the supervisor's
484/// bring-up wait.
485///
486/// A `TaskNode` can gate on any number of slots of *different* `T`s, so the node
487/// stores `&'static [&'static dyn ResourceGate]` (object-safe: no `T` in the
488/// signatures). Same shape as embassy's `dyn` driver registries — see
489/// <https://doc.rust-lang.org/reference/items/traits.html#object-safety>.
490/// The supervisor only needs "is it filled?" plus the signal to park on; taking
491/// the value stays in the generated spawn glue, where the concrete `T` is known.
492pub trait ResourceGate: Sync {
493 /// Non-consuming "is the slot currently filled" check.
494 fn is_filled(&self) -> bool;
495 /// The latching [`Signal`] fired by `provide`/`restore`, for the supervisor's
496 /// bounded pre-spawn wait (see [`Supervisor::start`]).
497 fn filled_signal(&self) -> &Signal<CriticalSectionRawMutex, ()>;
498}
499
500/// A one-value handoff cell threading an owned resource from `main` into a
501/// supervised task — the safe replacement for `Peripherals::steal()` inside
502/// the task body.
503///
504/// Declared (as a `pub static`) by [`supervisor_graph!`] for each entry in a
505/// node's `resources:` clause. The protocol:
506///
507/// 1. `main` splits `Peripherals` and **moves** the resource in with
508/// [`provide`](Self::provide). This is where the compile-time guarantee
509/// lives: the singleton field is *consumed*, so no second owner — and no
510/// `unsafe` steal — can exist.
511/// 2. The generated spawn glue [`take`](Self::take)s it just before spawning
512/// the node. An empty slot fails the spawn with `SpawnError::Busy` — a
513/// fail-closed error out of [`Supervisor::start`], not a panic inside the
514/// task (compare `static_cell::StaticCell`, which panics on misuse).
515/// 3. The generated task shell hands the worker `&mut T` and
516/// [`restore`](Self::restore)s the value after the worker returns, so a
517/// `Terminate` respawn re-takes the *same instance* instead of stealing a
518/// fresh one. (A `Pause` worker never returns — it parks — so it simply
519/// retains the resource, exactly like a hand-written parked task.)
520///
521/// Same primitives as [`SpawnerSlot`]: a critical-section
522/// [`BlockingMutex`]`<`[`Cell`]`<Option<T>>>` for the value (`Sync` for
523/// `T: Send`, provided by embassy-sync — no `unsafe` here) plus a latching
524/// [`Signal`] so the supervisor can await late provisioning (bounded; see
525/// [`Supervisor::start`]).
526pub struct ResourceSlot<T> {
527 slot: BlockingMutex<CriticalSectionRawMutex, Cell<Option<T>>>,
528 /// Wakes the supervisor's pre-spawn wait when `provide`/`restore` fills the
529 /// slot (latching, so a fill racing the check-then-wait still wakes it).
530 filled: Signal<CriticalSectionRawMutex, ()>,
531}
532
533impl<T> ResourceSlot<T> {
534 /// An empty slot (`const` — it lives in a `static` the macro emits).
535 pub const fn new() -> Self {
536 Self {
537 slot: BlockingMutex::new(Cell::new(None)),
538 filled: Signal::new(),
539 }
540 }
541
542 /// Move the resource in (from `main`'s `Peripherals` split) and wake the
543 /// supervisor's pre-spawn wait. Call before [`Supervisor::start`]; a slot
544 /// still empty after the supervisor's bounded wait fails that node's spawn
545 /// with `SpawnError::Busy`. Filling an occupied slot replaces (drops) the
546 /// old value — don't: one resource, one slot, moved exactly once.
547 pub fn provide(&self, value: T) {
548 self.slot.lock(|c| c.set(Some(value)));
549 self.filled.signal(());
550 }
551
552 /// Take the resource out, leaving the slot empty. Called by the generated
553 /// spawn glue just before the spawn; `None` means "not provided yet" or
554 /// "currently held by a live task instance".
555 pub fn take(&self) -> Option<T> {
556 self.slot.lock(Cell::take)
557 }
558
559 /// Put the resource back for the next spawn. Called by the generated task
560 /// shell after the worker returns (i.e. after its clean shutdown ack), so a
561 /// respawn re-takes the same instance.
562 pub fn restore(&self, value: T) {
563 self.provide(value);
564 }
565}
566
567// `T: Send` (not just any `T`): the gate is reachable from the supervisor task,
568// which may run on a different core than the provider — the same bound the
569// inner `BlockingMutex` requires for `Sync`, restated here so the `dyn` upcast
570// can't outrun it.
571impl<T: Send> ResourceGate for ResourceSlot<T> {
572 fn is_filled(&self) -> bool {
573 // Peek without consuming: `Cell` has no `&T` access (no `T: Copy`
574 // here), so take-and-put-back under the same critical section.
575 self.slot.lock(|c| {
576 let v = c.take();
577 let filled = v.is_some();
578 c.set(v);
579 filled
580 })
581 }
582
583 fn filled_signal(&self) -> &Signal<CriticalSectionRawMutex, ()> {
584 &self.filled
585 }
586}
587
588impl<T> Default for ResourceSlot<T> {
589 fn default() -> Self {
590 Self::new()
591 }
592}
593
594// ─── TaskNode ────────────────────────────────────────────────────────────
595
596/// A node in the supervisor's task graph.
597///
598/// Designed to live in `static` memory: every field is `Sync`, all constructors
599/// are `const`. Declared by [`supervisor_graph!`], which emits one per managed
600/// task along with the [`Graph`] (`GRAPH`) that [`Supervisor::new`] consumes.
601pub struct TaskNode {
602 /// Human-readable name. Used in defmt logs and panic messages.
603 pub name: &'static str,
604 /// Lifecycle policy. See [`Mode`].
605 pub mode: Mode,
606 /// App-provided spawn function (typically an inline closure at the node's
607 /// declaration). Called once at boot from `Supervisor::start`, again from
608 /// `respawn_terminate` for Terminate nodes, and at runtime from `start_node`
609 /// for `OnDemand` nodes. `None` for a **parked** node the application spawns
610 /// itself (e.g. a `Pause` sensor holding a peripheral handle): the supervisor
611 /// tracks its lifecycle but never spawns it.
612 pub spawn: Option<fn(Spawner) -> Result<(), SpawnError>>,
613 /// The executor [`SpawnerSlot`] this node spawns through (`executor: NAME` in
614 /// the graph), or `None` to spawn on the supervisor's own `Spawner`. When
615 /// `Some`, the supervisor awaits the slot's [`ready`](SpawnerSlot::ready)
616 /// (bounded by [`SLOT_READY_TIMEOUT`]) *before* invoking `spawn`, so the
617 /// generated glue's own non-blocking `SpawnerSlot::get` is already filled. Set
618 /// by the macro via [`with_executor`](Self::with_executor); `const`, zero-cost.
619 spawn_slot: Option<&'static SpawnerSlot>,
620 /// The [`ResourceSlot`]s this node's spawn takes from (`resources:` in the
621 /// graph), type-erased to their [`ResourceGate`] readiness view. The
622 /// supervisor awaits every gate being filled (bounded by
623 /// [`SLOT_READY_TIMEOUT`]) *before* invoking `spawn`, so (a) a `main` that
624 /// provides late is tolerated and (b) a respawn cannot race the previous
625 /// instance's shell restoring the value (the restore happens after the
626 /// worker's shutdown ack). Empty for nodes without `resources:`. Set by the
627 /// macro via [`with_resources`](Self::with_resources); `const`, zero-cost.
628 resource_gates: &'static [&'static dyn ResourceGate],
629 handle: TaskHandle,
630}
631
632impl TaskNode {
633 /// A single-instance node started at boot (`Terminate`/`Pause`) or on demand
634 /// (`Mode::OnDemand`). Every node is single-instance; an elastic service is
635 /// modelled as several `OnDemand` nodes of the same pooled task fn.
636 ///
637 /// A `TaskNode` carries only its own identity and behaviour; the graph's
638 /// dependency edges live in the compile-time index table that
639 /// [`supervisor_graph!`] emits and [`Supervisor::new`] consumes.
640 /// `disabled_at_boot` seeds the node's disabled flag so a control-started node
641 /// (e.g. an OTA task) can be declared down and started later via a control op.
642 /// `spawn` is `None` for a parked node the application spawns itself.
643 pub const fn new(
644 name: &'static str,
645 mode: Mode,
646 spawn: Option<fn(Spawner) -> Result<(), SpawnError>>,
647 disabled_at_boot: bool,
648 ) -> Self {
649 Self {
650 name,
651 mode,
652 spawn,
653 spawn_slot: None,
654 resource_gates: &[],
655 handle: TaskHandle::new(disabled_at_boot),
656 }
657 }
658
659 /// Route this node's spawn through the given executor [`SpawnerSlot`] (the
660 /// `executor: NAME` graph annotation). The supervisor awaits the slot before
661 /// spawning the node, so a tier filled late — or from another core — is handled
662 /// without a race, and the generated glue's non-blocking `get` is already filled.
663 /// `const` and chainable in a `static` initializer; emitted by [`supervisor_graph!`].
664 pub const fn with_executor(mut self, slot: &'static SpawnerSlot) -> Self {
665 self.spawn_slot = Some(slot);
666 self
667 }
668
669 /// Declare the [`ResourceSlot`]s this node's spawn takes from (the
670 /// `resources:` graph clause). The supervisor awaits every gate being
671 /// filled before spawning the node, so the generated glue's non-blocking
672 /// `take()` finds the value. `const` and chainable in a `static`
673 /// initializer; emitted by [`supervisor_graph!`].
674 pub const fn with_resources(mut self, gates: &'static [&'static dyn ResourceGate]) -> Self {
675 self.resource_gates = gates;
676 self
677 }
678
679 // ── Task-side API ────────────────────────────────────────────────────
680 //
681 // Called from inside the `#[embassy_executor::task] async fn` body. The
682 // whole task-side protocol is four rules (the README's "Writing supervised
683 // tasks" section has per-mode skeletons):
684 // 1. select long-lived work against `wait_shutdown()`;
685 // 2. `ack_dropped()` exactly once per stop — on exit (Terminate/OnDemand)
686 // or on each pause (Pause), before parking on `wait_resume()`;
687 // 3. an autonomous exit acks too;
688 // 4. resources follow the mode: Terminate re-acquires on respawn, Pause
689 // retains across park.
690
691 /// True iff the supervisor has requested shutdown. Checked at the loop top
692 /// alongside `wait_shutdown()` in a `select`.
693 pub fn shutdown_requested(&self) -> bool {
694 self.handle.shutdown.load(Ordering::Acquire)
695 }
696
697 /// Park until shutdown is requested. Returns immediately if shutdown has
698 /// already been requested. Use this for single-instance tasks in a `select`
699 /// against the task's main work future.
700 pub async fn wait_shutdown(&self) {
701 // Fast path — already requested. (Important because the signal is
702 // edge-triggered: if `signal()` fired before we got here, the bare
703 // `wait()` below would block forever.)
704 if self.handle.shutdown.load(Ordering::Acquire) {
705 return;
706 }
707 self.handle.shutdown_wake.wait().await;
708 }
709
710 /// Mark this instance as having shut down: clears the running flag and acks
711 /// the teardown handshake (so the supervisor's `wait_dropped` completes).
712 /// Every instance must call this exactly once on exit (Terminate/OnDemand
713 /// mode) or on each pause (Pause mode). It also covers an **autonomous** exit
714 /// the supervisor didn't request — e.g. a pool worker backing off — so the
715 /// pool sees the instance as down and can re-grow it under later demand.
716 pub fn ack_dropped(&self) {
717 self.handle.running.store(false, Ordering::Release);
718 self.handle.dropped.store(true, Ordering::Release);
719 self.handle.dropped_wake.signal(());
720 }
721
722 /// Pause-mode only: park until the supervisor signals resume. Call *after*
723 /// [`ack_dropped`](Self::ack_dropped) — ack the pause, then park; held
724 /// resources stay owned across the park.
725 pub async fn wait_resume(&self) {
726 self.handle.resume_wake.wait().await;
727 }
728
729 /// Report that this task started serving a request (active). Fires the
730 /// scale-request signal on a real idle→busy transition so the scaling policy
731 /// can react (e.g. grow the pool); a redundant call doesn't re-signal.
732 pub fn mark_busy(&self) {
733 if !self.handle.busy.swap(true, Ordering::Release) {
734 request_scale();
735 }
736 }
737
738 /// Report that this task finished serving and is idle again. Fires the
739 /// scale-request signal on a real busy→idle transition so the scaling policy
740 /// can react (e.g. shrink the pool); a redundant call doesn't re-signal.
741 pub fn mark_idle(&self) {
742 if self.handle.busy.swap(false, Ordering::Release) {
743 request_scale();
744 }
745 }
746
747 /// True while this task is actively serving. Read by the scaling policy.
748 pub fn is_busy(&self) -> bool {
749 self.handle.busy.load(Ordering::Acquire)
750 }
751
752 /// True while the supervisor has this node spawned (and it hasn't exited).
753 /// Read by the scaling policy to count live instances, and by a task-state
754 /// view.
755 pub fn is_running(&self) -> bool {
756 self.handle.running.load(Ordering::Acquire)
757 }
758
759 /// True while the node is disabled: declared `disabled` in the graph
760 /// (stopped-at-boot, up on an explicit `Activate`), or manually deactivated
761 /// via the control interface and not yet re-activated. Read by a task-state
762 /// view and by the automatic bring-up paths (which skip a disabled node).
763 pub fn is_disabled(&self) -> bool {
764 self.handle.disabled.load(Ordering::Acquire)
765 }
766
767 /// Mark/clear this node as **detached**: a self-managing node the supervisor
768 /// brings up once (via [`start`](Supervisor::start)) and then stops managing
769 /// **entirely**. Every runtime lifecycle operation skips a detached node: full
770 /// [`teardown`](Supervisor::teardown), the control deactivate/activate cascades,
771 /// [`stop_node`](Supervisor::stop_node), [`respawn_terminate`](Supervisor::respawn_terminate),
772 /// and pause-resume. It keeps running (or, for a one-shot, stays exited) across a
773 /// teardown/wake cycle instead of being stopped, re-enabled, or re-spawned. Use it
774 /// for a task that must outlive the teardown it participates in — e.g. a sleep/power
775 /// coordinator that tears the graph down, sleeps, then wakes it — or a self-managed
776 /// one-shot whose `deps:` exist only for start-ordering. The node owns its own
777 /// shutdown; the supervisor will not drive it.
778 pub fn set_detached(&self, detached: bool) {
779 self.handle.detached.store(detached, Ordering::Release);
780 }
781
782 /// True while this node is [detached](Self::set_detached): self-managed, skipped by
783 /// every runtime lifecycle operation (teardown, deactivate/activate, `stop_node`,
784 /// respawn, pause-resume). Only the initial `start` brings it up.
785 pub fn is_detached(&self) -> bool {
786 self.handle.detached.load(Ordering::Acquire)
787 }
788
789 // ── Trace/observability API (features `trace`/`trace-names`) ───────────
790
791 /// Record the executor task id (`SpawnToken::id()` / `TaskRef::id()`) currently
792 /// backing this node, so the [`trace`] recorders can attribute executor polls to
793 /// it. Called automatically by the spawn glue `supervisor_graph!` generates;
794 /// call it manually only for a **parked** node (no `spawn:`) or a verbatim-closure
795 /// `spawn:`, where the macro cannot see the token. Overwrites on every (re)spawn.
796 #[cfg(feature = "trace")]
797 pub fn set_task_id(&self, id: u32) {
798 self.handle.task_id.store(id, Ordering::Release);
799 }
800
801 /// Register an externally-spawned token as this node's live task: records
802 /// the task id for the [`trace`] recorders and (feature `metadata-names`)
803 /// stamps the node name into the task Metadata. One call replaces the
804 /// manual [`set_task_id`](Self::set_task_id) dance wherever the macro can't
805 /// see the token — parked nodes and verbatim-closure `spawn:` forms:
806 ///
807 /// ```ignore
808 /// let t = environment_task(i2c_dev)?;
809 /// BME280.adopt(&t);
810 /// high_spawner.spawn(t);
811 /// ```
812 #[cfg(feature = "trace")]
813 pub fn adopt<S>(&self, token: &embassy_executor::SpawnToken<S>) {
814 self.set_task_id(token.id());
815 #[cfg(feature = "metadata-names")]
816 self.stamp_name(token);
817 }
818
819 /// Stamp this node's name into the task's embassy `Metadata` (feature
820 /// `metadata-names`), so external consumers — rtos-trace/SystemView, debuggers —
821 /// show the graph node name instead of an opaque task id. Unlike
822 /// [`adopt`](Self::adopt) this does **not** capture the task id or touch the
823 /// supervisor's [`trace`] recorders, so it needs neither the `trace` feature nor
824 /// the `_embassy_trace_*` hook symbols: it is the name-only spawn path emitted
825 /// when `metadata-names` is on but `trace` is off (pair it with embassy's
826 /// `rtos-trace`). Called automatically by the spawn glue; call it manually only
827 /// for a parked or verbatim-closure node the macro can't see.
828 ///
829 /// Requires `embassy-executor`'s `metadata-name` feature, which `metadata-names`
830 /// pulls in; without a registered name the task keeps embassy's default.
831 #[cfg(feature = "metadata-names")]
832 pub fn stamp_name<S>(&self, token: &embassy_executor::SpawnToken<S>) {
833 token.metadata().set_name(self.name);
834 }
835
836 /// The executor task id last recorded by [`set_task_id`](Self::set_task_id)
837 /// (`0` = never spawned / not registered).
838 #[cfg(feature = "trace")]
839 pub fn task_id(&self) -> u32 {
840 self.handle.task_id.load(Ordering::Acquire)
841 }
842
843 /// Accumulated executor-poll time of this node, in embassy-time ticks. Wrapping:
844 /// sample twice and `wrapping_sub` the readings to get a rate over a window.
845 #[cfg(feature = "trace")]
846 pub fn exec_ticks(&self) -> u32 {
847 self.handle.exec_ticks.load(Ordering::Relaxed)
848 }
849
850 /// Number of executor polls of this node (wrapping counter).
851 #[cfg(feature = "trace")]
852 pub fn poll_count(&self) -> u32 {
853 self.handle.polls.load(Ordering::Relaxed)
854 }
855
856 /// Longest single executor poll of this node ever observed, in ticks — the
857 /// "never yields" watermark. A poll is expected to be microseconds; a large
858 /// value names the node that hogged its executor, even after the fact.
859 #[cfg(feature = "trace")]
860 pub fn max_poll_ticks(&self) -> u32 {
861 self.handle.max_poll_ticks.load(Ordering::Relaxed)
862 }
863
864 // ── Supervisor-side API ──────────────────────────────────────────────
865 //
866 // Driven by the `Supervisor` struct. Kept `pub(crate)` so app code doesn't
867 // accidentally bypass the supervisor's orchestration.
868
869 pub(crate) fn signal_shutdown(&self) {
870 self.handle.shutdown.store(true, Ordering::Release);
871 self.handle.shutdown_wake.signal(());
872 }
873
874 pub(crate) fn signal_resume(&self) {
875 self.handle.resume_wake.signal(());
876 }
877
878 pub(crate) fn set_running(&self, running: bool) {
879 self.handle.running.store(running, Ordering::Release);
880 }
881
882 /// Set/clear the manual-deactivation flag. Set by `Supervisor::deactivate`,
883 /// cleared by `Supervisor::activate`. Deliberately *not* touched by
884 /// `reset()`, so a manual stop survives respawn cycles and RAM-retaining
885 /// power-state transitions.
886 ///
887 /// Public so an application can pre-disable a `Terminate` node *before*
888 /// `Supervisor::start`, making it a stopped-at-boot task that only comes up on
889 /// an explicit `Activate` control (a node started by control rather than at boot).
890 pub fn set_disabled(&self, disabled: bool) {
891 self.handle.disabled.store(disabled, Ordering::Release);
892 }
893
894 /// Wait until the instance has called `ack_dropped()`. Single-instance, so
895 /// one ack ends the wait. The fast-path flag check handles the ack landing
896 /// before this await (the `dropped_wake` signal is edge-triggered).
897 pub(crate) async fn wait_dropped(&self) {
898 if self.handle.dropped.load(Ordering::Acquire) {
899 return;
900 }
901 self.handle.dropped_wake.wait().await;
902 }
903
904 /// Clear the shutdown flag, dropped flag, busy flag, and the shutdown /
905 /// dropped wake-signals so the next cycle starts clean. Doesn't touch
906 /// `running` (managed around spawn/stop), `resume_wake` (`resume_pausable`
907 /// fires that for Pause nodes), or `disabled` (lifecycle-spanning).
908 pub(crate) fn reset(&self) {
909 self.handle.shutdown.store(false, Ordering::Release);
910 self.handle.dropped.store(false, Ordering::Release);
911 self.handle.busy.store(false, Ordering::Release);
912 self.handle.shutdown_wake.reset();
913 self.handle.dropped_wake.reset();
914 }
915}
916
917/// Manual impl: the private `TaskHandle` (Signals + atomics) has no `Debug`, and a
918/// snapshot of the *live* flags is more useful than raw handle internals anyway.
919/// `finish_non_exhaustive` marks the elided fields (`spawn`, the handle).
920impl core::fmt::Debug for TaskNode {
921 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
922 f.debug_struct("TaskNode")
923 .field("name", &self.name)
924 .field("mode", &self.mode)
925 .field("running", &self.is_running())
926 .field("busy", &self.is_busy())
927 .field("disabled", &self.is_disabled())
928 .field("detached", &self.is_detached())
929 .finish_non_exhaustive()
930 }
931}
932
933// ─── Graph ───────────────────────────────────────────────────────────────
934
935/// The compile-time task graph produced by [`supervisor_graph!`]: the node slots,
936/// the dependency-index table, the topological order, and the elastic pools — the
937/// single value [`Supervisor::new`] consumes. The macro emits one `pub static GRAPH`
938/// of this type. The fields are public so the application can read them directly
939/// (e.g. a status endpoint iterating `GRAPH.nodes` / `GRAPH.deps`).
940///
941/// `N` is capped at 256 (graph indices are `u8`); the macro enforces this at
942/// expansion time.
943pub struct Graph<const N: usize> {
944 /// Node slots, one per declared node. `None` marks a `#[cfg]`-ed-out node.
945 pub nodes: &'static [Option<&'static TaskNode>; N],
946 /// Per-node dependency indices into `nodes` (`deps[i]` lists node `i`'s deps).
947 pub deps: &'static [&'static [u8]; N],
948 /// Topologically sorted indices into `nodes` (dependencies before dependents;
949 /// reverse iteration is the teardown order). A dependency cycle is a compile error.
950 pub order: [u8; N],
951 /// Elastic worker pools to register with the supervisor (empty when unused).
952 #[cfg(feature = "pool")]
953 pub pools: &'static [&'static dyn Pool],
954}
955
956// ─── Supervisor ──────────────────────────────────────────────────────────
957
958/// Orchestrates a set of managed tasks across spawn / teardown / bring-up.
959///
960/// Owned by a single supervisor task. Concurrent access from other tasks goes
961/// through each [`TaskNode`]'s own atomic state, not the `Supervisor` struct.
962pub struct Supervisor<const N: usize> {
963 /// Node slots, one per declared node. `None` marks a slot whose node was
964 /// `#[cfg]`-ed out of the build (feature-gated); every method skips those.
965 nodes: &'static [Option<&'static TaskNode>],
966 /// Per-node dependency indices into `nodes` (`deps[i]` lists the indices of
967 /// the nodes that node `i` depends on). The single runtime source of graph
968 /// topology, generated alongside `order` by the `supervisor_graph!` macro.
969 deps: &'static [&'static [u8]],
970 /// Topologically sorted indices into `nodes`: dependencies before their
971 /// dependents; reverse iteration is the teardown order. Precomputed at
972 /// compile time (a cycle is a compile error), so construction does no work.
973 order: [u8; N],
974 /// Elastic pools, so the control interface can co-control a whole pool from
975 /// any one member (`apply_control` expands the target through
976 /// [`Pool::members`]) — the same registry `run_pools` drives. Taken from
977 /// `GRAPH.pools` at construction (empty when no pool is declared).
978 #[cfg(feature = "pool")]
979 pools: &'static [&'static dyn Pool],
980}
981
982/// Await a node's `executor:` [`SpawnerSlot`] (if it has one), bounded by
983/// [`SLOT_READY_TIMEOUT`]. A slot still empty after the wait yields
984/// [`SpawnError::Busy`] — a loud misconfiguration, not a silent hang. A node with no
985/// slot returns immediately, so a same-executor bring-up never touches the timer.
986async fn await_spawn_slot(node: &'static TaskNode) -> Result<(), SpawnError> {
987 if let Some(slot) = node.spawn_slot {
988 with_timeout(SLOT_READY_TIMEOUT, slot.ready())
989 .await
990 .map_err(|_| SpawnError::Busy)?;
991 }
992 Ok(())
993}
994
995/// Await every [`ResourceSlot`] a node's `resources:` clause takes from being
996/// filled, bounded by [`SLOT_READY_TIMEOUT`] per gate. Covers two windows:
997/// `main` providing after `start` was entered, and — on respawn — the previous
998/// instance's shell still between the shutdown ack and its `restore()` call
999/// (on another core the two can genuinely overlap). A gate still empty at the
1000/// deadline yields [`SpawnError::Busy`] — an unprovided slot is a loud
1001/// misconfiguration, not a silent hang. Nodes without `resources:` have an
1002/// empty gate list and never touch the timer. Same check-then-park loop as
1003/// [`SpawnerSlot::ready`]; the `filled` signal latches, so a fill racing the
1004/// check still wakes the wait (and the same single-pre-fill-waiter caveat
1005/// applies — the supervisor task is the only intended waiter).
1006async fn await_resources(node: &'static TaskNode) -> Result<(), SpawnError> {
1007 for gate in node.resource_gates {
1008 let wait = async {
1009 loop {
1010 if gate.is_filled() {
1011 break;
1012 }
1013 gate.filled_signal().wait().await;
1014 }
1015 };
1016 with_timeout(SLOT_READY_TIMEOUT, wait)
1017 .await
1018 .map_err(|_| SpawnError::Busy)?;
1019 }
1020 Ok(())
1021}
1022
1023impl<const N: usize> Supervisor<N> {
1024 /// Build a supervisor from a precomputed [`Graph`] — the `GRAPH` that
1025 /// `supervisor_graph!` emits (node slots, dependency-index table, compile-time
1026 /// topological `order`, and the elastic pools). A dependency cycle is a
1027 /// *compile* error, so construction is infallible and does no work —
1028 /// `start` / `teardown` / `respawn_terminate` just iterate.
1029 pub const fn new(graph: &'static Graph<N>) -> Self {
1030 Self {
1031 nodes: graph.nodes,
1032 deps: graph.deps,
1033 order: graph.order,
1034 #[cfg(feature = "pool")]
1035 pools: graph.pools,
1036 }
1037 }
1038
1039 /// Spawn every boot node in dependency order. Called once at boot.
1040 /// `Mode::OnDemand` nodes are skipped — they're brought up at runtime by
1041 /// `start_node`. A **parked** node (no `spawn` fn) is spawned externally by
1042 /// `main()` (with hardware handles main owns); it's still marked `running`
1043 /// here. Disabled nodes, and `#[cfg]`-ed-out slots, are skipped.
1044 ///
1045 /// Async because an `executor: NAME` node first awaits its [`SpawnerSlot::ready`]
1046 /// (bounded by `SLOT_READY_TIMEOUT` — the rendezvous with a tier or second core
1047 /// that comes up asynchronously); a slot still empty at the deadline fails the
1048 /// bring-up with [`SpawnError::Busy`]. A node with no `executor:` slot never
1049 /// touches the timer.
1050 pub async fn start(&self, spawner: Spawner) -> Result<(), SpawnError> {
1051 // Register the node slots with the trace recorders.
1052 #[cfg(feature = "trace")]
1053 trace::register_graph(self.nodes);
1054
1055 for i in self.order.iter() {
1056 let Some(node) = self.nodes[*i as usize] else {
1057 continue;
1058 };
1059 if matches!(node.mode, Mode::OnDemand) || node.is_disabled() {
1060 continue;
1061 }
1062 info!("supervisor: spawning {} ({})", node.name, node.mode);
1063 if let Some(spawn) = node.spawn {
1064 // For an `executor:` node, wait (bounded) for its slot to be filled
1065 // before spawning; a same-executor node has no slot, so this is an
1066 // immediate no-op and the bring-up loop stays tight. Then wait for
1067 // the node's `resources:` slots (if any) so the glue's take() finds
1068 // the value even if main provides late.
1069 await_spawn_slot(node).await?;
1070 await_resources(node).await?;
1071 spawn(spawner)?;
1072 }
1073 node.set_running(true);
1074 }
1075 Ok(())
1076 }
1077
1078 /// Start a single node at runtime — e.g. growing an elastic pool. Resets the
1079 /// handle, spawns one instance via the node's `spawn` fn (which must launch
1080 /// exactly one), and marks it `running`. Returns `SpawnError::Busy` if the
1081 /// underlying embassy task pool is exhausted (the ceiling), which the caller
1082 /// treats as "can't grow".
1083 pub async fn start_node(
1084 &self,
1085 node: &'static TaskNode,
1086 spawner: Spawner,
1087 ) -> Result<(), SpawnError> {
1088 node.reset();
1089 if let Some(spawn) = node.spawn {
1090 await_spawn_slot(node).await?;
1091 await_resources(node).await?;
1092 spawn(spawner)?;
1093 }
1094 node.set_running(true);
1095 info!("supervisor: started {}", node.name);
1096 Ok(())
1097 }
1098
1099 /// Signal `node` to shut down, wait for its ack (panicking on timeout — a
1100 /// missing `ack_dropped()` somewhere), then clear `running`. Shared by
1101 /// `stop_node` and `teardown`; the caller must have checked `is_running`.
1102 async fn shutdown_and_wait(&self, node: &'static TaskNode) {
1103 node.signal_shutdown();
1104 if let Either::Second(()) = select(
1105 node.wait_dropped(),
1106 Timer::after_millis(SHUTDOWN_ACK_TIMEOUT_MS),
1107 )
1108 .await
1109 {
1110 panic!(
1111 "supervisor: task {} did not ack shutdown within {}ms",
1112 node.name, SHUTDOWN_ACK_TIMEOUT_MS,
1113 );
1114 }
1115 node.set_running(false);
1116 }
1117
1118 /// Stop a single running node at runtime — e.g. shrinking an elastic pool.
1119 /// Signals shutdown, waits for the ack, clears `running`. No-op if the node
1120 /// isn't running, or is [detached](TaskNode::set_detached) (self-managed — the
1121 /// supervisor never stops it). Panics if it doesn't ack within the timeout.
1122 pub async fn stop_node(&self, node: &'static TaskNode) {
1123 if !node.is_running() || node.is_detached() {
1124 return;
1125 }
1126 self.shutdown_and_wait(node).await;
1127 info!("supervisor: stopped {}", node.name);
1128 }
1129
1130 /// Signal every **running** node to shut down in **reverse** topological
1131 /// order, awaiting each node's ack before moving to its dependency. Down
1132 /// `OnDemand` nodes are skipped (no instance to ack). Pause-mode nodes ack
1133 /// and park on `wait_resume()`; Terminate/OnDemand nodes exit. Panics if a
1134 /// running node fails to ack within `SHUTDOWN_ACK_TIMEOUT_MS`.
1135 pub async fn teardown(&self) {
1136 for i in self.order.iter().rev() {
1137 let Some(node) = self.nodes[*i as usize] else {
1138 continue;
1139 };
1140 if !node.is_running() {
1141 continue;
1142 }
1143 // A detached node is self-managed; never tear it down. See
1144 // [`TaskNode::set_detached`].
1145 if node.is_detached() {
1146 continue;
1147 }
1148 info!("supervisor: tearing down {}", node.name);
1149 self.shutdown_and_wait(node).await;
1150 }
1151 }
1152
1153 /// Signal every Pause-mode node to resume. Cheap and synchronous — the tasks
1154 /// were parked on `wait_resume()` and pick up immediately. Called separately
1155 /// from `respawn_terminate` so the application can fire resume independently
1156 /// of the respawn step. Disabled (manually-paused) nodes are skipped so a
1157 /// manual pause sticks, and detached (self-managed) Pause nodes are left
1158 /// parked; there is intentionally no dependency gate here.
1159 pub fn resume_pausable(&self) {
1160 for i in self.order.iter() {
1161 let Some(node) = self.nodes[*i as usize] else {
1162 continue;
1163 };
1164 if matches!(node.mode, Mode::Pause) && !node.is_disabled() && !node.is_detached() {
1165 node.reset();
1166 info!("supervisor: resuming {}", node.name);
1167 node.signal_resume();
1168 node.set_running(true);
1169 }
1170 }
1171 }
1172
1173 /// Reset and re-spawn every Terminate-mode node in dependency order.
1174 /// Pause-mode nodes are untouched (use `resume_pausable`); `OnDemand` nodes
1175 /// are left down — they re-grow under load via `start_node`. Disabled nodes
1176 /// are skipped so a manual stop sticks across the bring-up. Detached nodes are
1177 /// skipped too: `teardown` never brought them down, so they are still running
1178 /// and re-spawning would double-spawn them (see [`TaskNode::set_detached`]). The
1179 /// reset happens before the spawn so newly-running tasks see a clean handle.
1180 pub async fn respawn_terminate(&self, spawner: Spawner) -> Result<(), SpawnError> {
1181 for i in self.order.iter() {
1182 let Some(node) = self.nodes[*i as usize] else {
1183 continue;
1184 };
1185 if matches!(node.mode, Mode::Terminate) && !node.is_disabled() && !node.is_detached() {
1186 node.reset();
1187 info!("supervisor: respawning {}", node.name);
1188 if let Some(spawn) = node.spawn {
1189 await_spawn_slot(node).await?;
1190 // A `resources:` node's previous instance restores its slot
1191 // value only after the shutdown ack, so wait (bounded) for
1192 // the restore before the glue's take().
1193 await_resources(node).await?;
1194 spawn(spawner)?;
1195 }
1196 node.set_running(true);
1197 }
1198 }
1199 Ok(())
1200 }
1201}
1202
1203// ─── Runtime control (dependency- and pool-honoring start/stop) ────────────
1204//
1205// The `apply_control` entry point drives one `ControlCommand` from the
1206// application's control surface. Unlike the pool's bare `start_node`/`stop_node`,
1207// these honor the graph: a stop cascades through dependents (so nothing is left
1208// running without a dependency), a start cascades through deps (so nothing comes
1209// up before what it needs), and either expands across a whole `ElasticPool` so
1210// the pool is controlled as a unit. A manual stop/pause also sets the
1211// lifecycle-spanning `disabled` flag, so it sticks against the elastic policy and
1212// the wake respawn.
1213
1214// Graph-index helpers used by BOTH the control plane and the pool driver, so they
1215// are gated on either feature — `pool` alone (no `control`) must still compile.
1216#[cfg(any(feature = "control", feature = "pool"))]
1217impl<const N: usize> Supervisor<N> {
1218 /// Position of `node` in `self.nodes` (pointer identity — every node is a
1219 /// `&'static`). `None` only if the node isn't in this graph (impossible for
1220 /// targets sourced from `GRAPH.nodes`; treated as a no-op by callers).
1221 fn index_of(&self, node: &'static TaskNode) -> Option<usize> {
1222 self.nodes
1223 .iter()
1224 .position(|n| n.is_some_and(|x| core::ptr::eq(x, node)))
1225 }
1226
1227 /// Whether every dependency of `node` is currently running, resolved through
1228 /// the graph's index table. The pool driver checks this before growing a
1229 /// worker, so a pool member is never spawned while one of its dependencies is
1230 /// down.
1231 #[cfg(feature = "pool")]
1232 pub(crate) fn deps_running(&self, node: &'static TaskNode) -> bool {
1233 match self.index_of(node) {
1234 Some(i) => self.deps[i]
1235 .iter()
1236 .all(|&di| self.nodes[di as usize].is_some_and(|n| n.is_running())),
1237 None => false,
1238 }
1239 }
1240}
1241
1242#[cfg(feature = "control")]
1243impl<const N: usize> Supervisor<N> {
1244 /// Seed a membership set with `target` plus — if `target` belongs to an
1245 /// elastic pool — every member of that pool, so control is applied to the
1246 /// whole pool atomically. Pool membership is read from `GRAPH.pools`; with no
1247 /// pools (the `pool` feature off, or none declared) this is just `{target}`.
1248 fn seed(&self, target: &'static TaskNode, set: &mut [bool; N]) {
1249 if let Some(i) = self.index_of(target) {
1250 set[i] = true;
1251 }
1252 #[cfg(feature = "pool")]
1253 for pool in self.pools {
1254 let members = pool.members();
1255 if members.iter().any(|m| core::ptr::eq(*m, target)) {
1256 for m in members {
1257 if let Some(i) = self.index_of(m) {
1258 set[i] = true;
1259 }
1260 }
1261 }
1262 }
1263 }
1264
1265 /// Apply one control command, honoring pool membership and the dependency
1266 /// graph. Run from the supervisor's driver loop (never concurrently with
1267 /// itself), so the cascade is atomic from the application's perspective.
1268 pub async fn apply_control(&self, cmd: ControlCommand, spawner: Spawner) {
1269 match cmd.op {
1270 ControlOp::Deactivate => self.deactivate(cmd.node).await,
1271 ControlOp::Activate => self.activate(cmd.node, spawner).await,
1272 }
1273 }
1274
1275 /// Bring `target` (and its pool, and every transitive dependent) down, in
1276 /// reverse-topological order so each dependent stops before the dependency it
1277 /// relies on. Marks the whole set `disabled` so the stop sticks against the
1278 /// elastic policy and the wake respawn until a matching `activate`.
1279 async fn deactivate(&self, target: &'static TaskNode) {
1280 let mut set = [false; N];
1281 self.seed(target, &mut set);
1282
1283 // Grow the set to include transitive dependents. `order` is
1284 // dependency-first, so when we reach a node its deps are already decided;
1285 // a node joins if any dep it declares is already in the set.
1286 for i in self.order.iter() {
1287 let j = *i as usize;
1288 if set[j] {
1289 continue;
1290 }
1291 let Some(node) = self.nodes[j] else {
1292 continue;
1293 };
1294 // A detached node declares its dep only for start ordering and intends
1295 // to outlive it, so it's never pulled into the cascade.
1296 if node.is_detached() {
1297 continue;
1298 }
1299 if self.deps[j].iter().any(|&di| set[di as usize]) {
1300 set[j] = true;
1301 }
1302 }
1303
1304 // Tear down in reverse topo order (dependents before their deps).
1305 for i in self.order.iter().rev() {
1306 let j = *i as usize;
1307 if !set[j] {
1308 continue;
1309 }
1310 let Some(node) = self.nodes[j] else {
1311 continue;
1312 };
1313 // A detached node is self-managed — never control-stop it. The growth loop
1314 // keeps detached *dependents* out of the set; this also covers a detached
1315 // node that was seeded directly (or a detached pool member). Without it a
1316 // detached one-shot that already exited (stale `is_running`, no ack path)
1317 // would be signalled a shutdown it can never acknowledge, panicking here.
1318 if node.is_detached() {
1319 continue;
1320 }
1321 node.set_disabled(true);
1322 if node.is_running() {
1323 info!("supervisor: control-stop {}", node.name);
1324 self.shutdown_and_wait(node).await;
1325 }
1326 }
1327 }
1328
1329 /// Bring `target` (and its pool, and every transitive dependency) up, in
1330 /// topological order so each dependency starts before its dependent. Clears
1331 /// `disabled` across the set. `OnDemand` (pool) members are only re-enabled,
1332 /// not force-spawned — the elastic policy re-grows them under load, which is
1333 /// the whole point of the pool.
1334 async fn activate(&self, target: &'static TaskNode, spawner: Spawner) {
1335 let mut set = [false; N];
1336 self.seed(target, &mut set);
1337
1338 // Grow the set to include transitive deps. Walk dependents-first
1339 // (reverse topo); when a set member is seen, pull in its direct deps.
1340 // A detached member's `deps:` are start-ordering only (the node is
1341 // self-managed), so don't expand from it — mirrors deactivate's guard;
1342 // otherwise activating a detached target would un-disable deps that
1343 // were independently disabled.
1344 for i in self.order.iter().rev() {
1345 let j = *i as usize;
1346 if set[j] && !self.nodes[j].is_some_and(|n| n.is_detached()) {
1347 for &di in self.deps[j] {
1348 set[di as usize] = true;
1349 }
1350 }
1351 }
1352
1353 // Bring up in topo order (deps before dependents).
1354 for i in self.order.iter() {
1355 let j = *i as usize;
1356 if !set[j] {
1357 continue;
1358 }
1359 let Some(node) = self.nodes[j] else {
1360 continue;
1361 };
1362 // A detached node is self-managed — the supervisor never re-enables or
1363 // re-starts it, even when it is a dependency of an activated target.
1364 if node.is_detached() {
1365 continue;
1366 }
1367 node.set_disabled(false);
1368 if node.is_running() {
1369 continue;
1370 }
1371 match node.mode {
1372 Mode::Terminate => {
1373 info!("supervisor: control-start {}", node.name);
1374 // SpawnError::Busy (pool exhausted) → can't start, skip.
1375 let _ = self.start_node(node, spawner).await;
1376 }
1377 Mode::Pause => {
1378 info!("supervisor: control-resume {}", node.name);
1379 node.reset();
1380 node.signal_resume();
1381 node.set_running(true);
1382 }
1383 // Pool worker — leave it down; the elastic policy regrows it on
1384 // demand now that `disabled` is cleared.
1385 Mode::OnDemand => {}
1386 }
1387 }
1388 }
1389}
1390
1391// ─── Topological sort (Kahn's algorithm, const) ───────────────────────────
1392//
1393// Computes the topological order at *compile time* over a per-node
1394// dependency-index table; a dependency cycle is a compile error.
1395
1396/// Topologically sort a graph given as a per-node dependency-index table.
1397///
1398/// `deps[i]` lists the indices of the nodes that node `i` depends on; the result
1399/// lists node indices in dependency-first order (a dependency appears before its
1400/// dependents). The supervisor iterates it forward for `start` /
1401/// `respawn_terminate` and in reverse for `teardown`.
1402///
1403/// Evaluated at compile time by the code `supervisor_graph!` generates — a
1404/// dependency **cycle is a compile error** (the `panic!` fires during const
1405/// evaluation). `#[doc(hidden)]`: an engine for the macro, not a user-facing API.
1406///
1407/// Supports at most 256 nodes: indices are `u8`, so a larger `N` would truncate.
1408/// The macro rejects bigger graphs at expansion; the assert below is defense in
1409/// depth for a manual caller (a const-eval panic, i.e. a compile error).
1410#[doc(hidden)]
1411#[must_use]
1412pub const fn topo_sort_const<const N: usize>(deps: &[&'static [u8]; N]) -> [u8; N] {
1413 assert!(
1414 N <= 256,
1415 "supervisor graph exceeds 256 node slots (indices are u8)"
1416 );
1417 // in_degree[i] = number of deps of node i not yet resolved.
1418 let mut in_degree = [0u8; N];
1419 let mut i = 0;
1420 while i < N {
1421 in_degree[i] = deps[i].len() as u8;
1422 i += 1;
1423 }
1424
1425 // Queue (fixed array, head/tail indices) seeded with the dependency-free nodes.
1426 let mut queue = [0u8; N];
1427 let mut tail = 0;
1428 i = 0;
1429 while i < N {
1430 if in_degree[i] == 0 {
1431 queue[tail] = i as u8;
1432 tail += 1;
1433 }
1434 i += 1;
1435 }
1436
1437 let mut order = [0u8; N];
1438 let mut produced = 0;
1439 let mut head = 0;
1440 while head < tail {
1441 let node = queue[head] as usize;
1442 head += 1;
1443 order[produced] = node as u8;
1444 produced += 1;
1445
1446 // Decrement the in-degree of every node that depends on `node`.
1447 let mut j = 0;
1448 while j < N {
1449 if in_degree[j] != 0 {
1450 let mut depends = false;
1451 let mut k = 0;
1452 while k < deps[j].len() {
1453 if deps[j][k] as usize == node {
1454 depends = true;
1455 }
1456 k += 1;
1457 }
1458 if depends {
1459 in_degree[j] -= 1;
1460 if in_degree[j] == 0 {
1461 queue[tail] = j as u8;
1462 tail += 1;
1463 }
1464 }
1465 }
1466 j += 1;
1467 }
1468 }
1469
1470 // A cycle leaves some nodes unproduced. During const eval this panic is a
1471 // compile error, so cyclic graphs are rejected at build time. `core::panic!`
1472 // (not the crate's defmt-shimmed `panic!`) keeps this const-evaluable.
1473 if produced != N {
1474 core::panic!("supervisor_graph!: dependency cycle");
1475 }
1476 order
1477}
1478
1479#[cfg(feature = "pool")]
1480mod pool;
1481#[cfg(feature = "pool")]
1482pub use pool::*;
1483
1484#[cfg(feature = "trace")]
1485pub mod trace;
1486
1487/// Declare a supervised task graph and compute its topological order at compile
1488/// time (single source of nodes, deps, pool, and order). See the
1489/// `embassy-supervisor-macros` crate for the surface syntax.
1490#[cfg(feature = "macros")]
1491pub use embassy_supervisor_macros::supervisor_graph;
1492
1493// ─── Tests (host-only) ─────────────────────────────────────────────────────
1494//
1495// Run on the host: `cargo test -p embassy-supervisor --target x86_64-unknown-linux-gnu`
1496// (the workspace `.cargo/config.toml` pins the embedded target, so `--target` is
1497// required to override it). These exercise the compile-time `topo_sort_const`
1498// over index adjacency tables — exactly what `supervisor_graph!` generates.
1499#[cfg(test)]
1500mod tests {
1501 use super::topo_sort_const;
1502
1503 /// Position of index `x` within `order`.
1504 fn pos<const N: usize>(order: &[u8; N], x: u8) -> usize {
1505 order.iter().position(|&y| y == x).expect("index present")
1506 }
1507
1508 #[test]
1509 fn linear_chain_orders_deps_before_dependents() {
1510 // A=0, B=1 dep A, C=2 dep B.
1511 const DEPS: [&[u8]; 3] = [&[], &[0], &[1]];
1512 const ORDER: [u8; 3] = topo_sort_const(&DEPS);
1513 assert_eq!(ORDER, [0, 1, 2]);
1514 }
1515
1516 #[test]
1517 fn diamond_puts_root_first_and_join_last() {
1518 // A=0; B=1 dep A; C=2 dep A; D=3 dep B,C.
1519 const DEPS: [&[u8]; 4] = [&[], &[0], &[0], &[1, 2]];
1520 const ORDER: [u8; 4] = topo_sort_const(&DEPS);
1521 assert_eq!(ORDER[0], 0, "root first");
1522 assert_eq!(ORDER[3], 3, "join last");
1523 assert!(pos(&ORDER, 1) < pos(&ORDER, 3), "B before D");
1524 assert!(pos(&ORDER, 2) < pos(&ORDER, 3), "C before D");
1525 }
1526
1527 #[test]
1528 fn independent_nodes_all_present() {
1529 const DEPS: [&[u8]; 2] = [&[], &[]];
1530 const ORDER: [u8; 2] = topo_sort_const(&DEPS);
1531 assert!(ORDER.contains(&0) && ORDER.contains(&1));
1532 }
1533
1534 #[test]
1535 fn unsorted_input_is_sorted() {
1536 // Declared out of dependency order: node 0 depends on 1 and 2, node 2 on 1.
1537 const DEPS: [&[u8]; 3] = [&[1, 2], &[], &[1]];
1538 const ORDER: [u8; 3] = topo_sort_const(&DEPS);
1539 assert!(pos(&ORDER, 1) < pos(&ORDER, 2), "1 before 2");
1540 assert!(pos(&ORDER, 2) < pos(&ORDER, 0), "2 before 0");
1541 }
1542
1543 #[test]
1544 fn evaluates_at_compile_time() {
1545 // The sort runs in a `const` context, proving it is const-evaluable.
1546 // (A cyclic table here would be a *compile* error, not a test failure.)
1547 const DEPS: [&[u8]; 3] = [&[], &[0], &[1]];
1548 const _: () = {
1549 let order = topo_sort_const(&DEPS);
1550 assert!(order[0] == 0 && order[1] == 1 && order[2] == 2);
1551 };
1552 }
1553
1554 // Uncommenting this must fail to compile ("dependency cycle"):
1555 // const CYCLE: [&[u8]; 2] = [&[1], &[0]];
1556 // const _BAD: [u8; 2] = topo_sort_const(&CYCLE);
1557}