Skip to main content

embassy_supervisor/
trace.rs

1//! Trace-hook observability (feature `trace`): a batteries-included consumer for
2//! embassy-executor's `_embassy_trace_*` instrumentation hooks.
3//!
4//! The executor (with its `trace` feature, enabled by this crate's `trace`) calls a
5//! set of `extern "Rust"` hooks on every poll, identifying tasks only by an opaque
6//! `u32` id. This module supplies what the raw hooks lack:
7//!
8//!   * **id → node resolution** — the spawn glue `supervisor_graph!` generates
9//!     captures each `SpawnToken`'s id into its [`TaskNode`] before spawning
10//!     ([`TaskNode::set_task_id`]), so a hook can attribute a poll to a node by
11//!     scanning the registered graph (O(N), N ≤ 256). Because the id is overwritten
12//!     on every (re)spawn, the mapping stays correct across respawns with no
13//!     unlinking — unlike an external task tracker.
14//!   * **per-node accounting** — accumulated poll time ([`TaskNode::exec_ticks`]),
15//!     poll count ([`TaskNode::poll_count`]), and the longest single poll
16//!     ([`TaskNode::max_poll_ticks`], the "never yields" watermark).
17//!   * **per-executor accounting** — idle time ([`executor_idle_ticks`]) and the
18//!     in-flight poll ([`current_task`] / [`stalled_task`]).
19//!
20//! With the companion `trace-hooks` feature, `supervisor_graph!` also *defines*
21//! the seven `no_mangle` hook symbols at the graph declaration site (they cannot
22//! live in this crate: `#![forbid(unsafe_code)]`, and `#[unsafe(no_mangle)]` is
23//! an unsafe attribute) — exactly one definition may exist per binary, so an
24//! application with its own hooks enables only `trace` and forwards to the
25//! recorder fns here instead.
26//!
27//! ## Semantics and limitations
28//!
29//!   * All counters are wrapping `u32`s of **embassy-time ticks**. Consumers sample
30//!     twice and `wrapping_sub` the readings to compute a rate over their own
31//!     window; the crate does no windowing of its own. Any single delta (a sample
32//!     window, or one uninterrupted idle stretch) longer than 2³² ticks (~71 min at
33//!     1 MHz) aliases — sample more often than that, and expect the idle counter to
34//!     under-report across very long sleeps.
35//!   * Accounting is **preemption-naive**: on systems with interrupt executors, a
36//!     thread-executor poll that gets preempted silently absorbs the preemptor's
37//!     CPU time, and idle is tracked per executor, not per core. Hardware-ISR time
38//!     is likewise invisible: during a poll it inflates that node, between polls it
39//!     lands in the unattributed share.
40//!   * Executor busy% exceeds the sum of per-node CPU% by a **per-poll accounting
41//!     gap** (executor bookkeeping + these hooks' own cost, dominated by the
42//!     O(N ≤ 256) id scan, not the two `Instant::now()` reads) — it grows with poll
43//!     rate. [`ExecutorStats`] owns the full busy/in-poll/overhead/unsupervised
44//!     decomposition.
45//!   * At most [`MAX_EXECUTORS`] executors are tracked (first come, first served);
46//!     hooks from further executors are dropped.
47//!   * Parked nodes (no `spawn:`) and verbatim-closure `spawn:` forms are not
48//!     auto-mapped — call [`TaskNode::set_task_id`] with the token id yourself.
49//!
50//! Docs: executor trace hooks: `embassy-executor/src/raw/trace.rs` (the hook ids
51//! are documented as implementation details, so this module pins to the executor
52//! minor version the crate already requires).
53
54use core::cell::Cell;
55use core::sync::atomic::Ordering;
56
57use embassy_sync::blocking_mutex::Mutex;
58use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
59use portable_atomic::{AtomicBool, AtomicU32, AtomicUsize};
60
61use crate::TaskNode;
62
63// ─── Graph registry ──────────────────────────────────────────────────────
64//
65// The registered node slice. A `static` can't hold a plain `&[..]` set at
66// runtime, and the crate is `forbid(unsafe_code)` (so no ptr+len atomics with a
67// `from_raw_parts` read); a blocking mutex around a `Cell` of the slice is the
68// safe equivalent — each access is one short critical section, entered once per
69// poll on the recording path.
70
71/// Up to this many graphs' node slices are tracked (named multi-graphs: each
72/// supervisor's `start()` registers its own). Registrations beyond the cap are
73/// silently dropped, like executor slots beyond `MAX_EXECUTORS`.
74pub const MAX_GRAPHS: usize = 4;
75
76static NODES: Mutex<
77    CriticalSectionRawMutex,
78    Cell<[&'static [Option<&'static TaskNode>]; MAX_GRAPHS]>,
79> = Mutex::new(Cell::new([&[]; MAX_GRAPHS]));
80
81/// Register a graph's node slots so the hook recorders can resolve task ids to
82/// nodes. Called automatically by [`Supervisor::start`](crate::Supervisor::start)
83/// with the graph's `nodes`; idempotent per slice (pointer-deduplicated), and
84/// with named multi-graphs each graph occupies one of the `MAX_GRAPHS` entries.
85pub fn register_graph(nodes: &'static [Option<&'static TaskNode>]) {
86    NODES.lock(|cell| {
87        let mut slices = cell.get();
88        if slices.iter().any(|s| core::ptr::eq(*s, nodes)) {
89            return;
90        }
91        if let Some(slot) = slices.iter_mut().find(|s| s.is_empty()) {
92            *slot = nodes;
93            cell.set(slices);
94        }
95    });
96}
97
98/// The registered graphs' node slots (all empty before any `register_graph`).
99fn graphs() -> [&'static [Option<&'static TaskNode>]; MAX_GRAPHS] {
100    NODES.lock(Cell::get)
101}
102
103/// Resolve an executor task id to its node: a linear scan over every registered
104/// graph's slots (id 0 = "unknown" is never matched). O(total nodes) with the
105/// per-graph cap at 256 — a handful of atomic loads per poll in practice.
106fn node_for(task_id: u32) -> Option<&'static TaskNode> {
107    if task_id == 0 {
108        return None;
109    }
110    graphs()
111        .into_iter()
112        .flatten()
113        .flatten()
114        .find(|n| n.task_id() == task_id)
115        .copied()
116}
117
118// ─── Per-executor slots ──────────────────────────────────────────────────
119
120/// Maximum number of executors tracked (thread executors + interrupt executors).
121/// Slots are claimed first come, first served; hooks from executors beyond the
122/// cap are silently dropped.
123pub const MAX_EXECUTORS: usize = 4;
124
125/// Live accounting for one executor. All fields are atomics: hooks may fire from
126/// interrupt-priority executors, so no locks anywhere on the recording path.
127struct ExecutorSlot {
128    /// The executor id owning this slot (`0` = free). Ids are the executor's
129    /// address bits, so `0` never collides with a real executor.
130    id: AtomicU32,
131    /// Task id currently inside `exec_begin..exec_end` (`0` = none).
132    current_task: AtomicU32,
133    /// Tick at which the current poll began.
134    current_begin: AtomicU32,
135    /// True between `executor_idle` and the next `poll_start`.
136    idle: AtomicBool,
137    /// Tick at which the executor went idle.
138    idle_since: AtomicU32,
139    /// Accumulated idle ticks (wrapping).
140    idle_ticks: AtomicU32,
141    /// Accumulated in-poll ticks (wrapping) over EVERY poll, resolvable to a
142    /// supervised node or not; see [`ExecutorStats`] for the busy/overhead/
143    /// unsupervised decomposition.
144    exec_ticks: AtomicU32,
145    /// Task polls on this executor (wrapping), supervised or not.
146    polls: AtomicU32,
147    /// Scheduler passes (`poll_start` events, wrapping). `polls / passes` is the
148    /// mean number of task polls per pass.
149    passes: AtomicU32,
150    /// Wall ticks stolen from the currently-open poll by nested higher-tier
151    /// polls (`trace-nested`): the victim's `exec_end` subtracts this before
152    /// attributing, making its numbers preemption-exact.
153    #[cfg(feature = "trace-nested")]
154    stolen_ticks: AtomicU32,
155}
156
157#[allow(clippy::declare_interior_mutable_const)] // const used only as array initializer
158const FREE_SLOT: ExecutorSlot = ExecutorSlot {
159    id: AtomicU32::new(0),
160    current_task: AtomicU32::new(0),
161    current_begin: AtomicU32::new(0),
162    idle: AtomicBool::new(false),
163    idle_since: AtomicU32::new(0),
164    idle_ticks: AtomicU32::new(0),
165    exec_ticks: AtomicU32::new(0),
166    polls: AtomicU32::new(0),
167    passes: AtomicU32::new(0),
168    #[cfg(feature = "trace-nested")]
169    stolen_ticks: AtomicU32::new(0),
170};
171
172// ─── Preemption stack (feature `trace-nested`) ───────────────────────────
173//
174// On ONE core, executor hooks nest strictly LIFO: a higher tier's whole
175// begin..end pair lands inside the preempted window. A tiny global stack of
176// slot indices tracks who is open, so a nested `exec_end` can credit its wall
177// time back to the window it interrupted (`stolen_ticks`). A preemption landing
178// in the `exec_end` epilogue (after the timestamp or after the `stolen` swap,
179// but before the pop) deposits into a window whose measured `raw` never
180// contained it; the leftover is then subtracted from the slot's NEXT poll — a
181// few ticks of edge noise. `saturating_sub` bounds that noise at zero: without
182// it, a stale deposit larger than a short next poll would underflow the u32 and
183// poison `exec_ticks`/`max_poll_ticks` with a ~4e9-tick garbage value.
184//
185// Multi-core: LIFO nesting only holds PER CORE — two cores' hooks interleave
186// arbitrarily. Registering a core-id fn ([`set_core_id_fn`]) switches to one
187// stack per core (nesting across cores does not exist: concurrent cores steal
188// nothing from each other, so no cross-core charge is needed). Without a
189// registered fn everything maps to core 0 — the single-core behavior.
190
191/// Number of per-core preemption stacks compiled in when `trace-nested` is
192/// enabled. Core indices from the registered fn are clamped to this.
193#[cfg(feature = "trace-nested")]
194pub const MAX_CORES: usize = 2;
195
196/// The app-registered core-id reader (see [`set_core_id_fn`]).
197#[cfg(feature = "trace-nested")]
198type CoreIdFn = fn() -> usize;
199#[cfg(feature = "trace-nested")]
200static CORE_ID_FN: Mutex<CriticalSectionRawMutex, Cell<Option<CoreIdFn>>> =
201    Mutex::new(Cell::new(None));
202
203/// Register how to read the current core's index (`trace-nested` on multi-core
204/// systems). The crate is HAL-agnostic, so the one-liner lives in the app — on
205/// RP2350: `trace::set_core_id_fn(|| embassy_rp::pac::SIO.cpuid().read() as usize)`.
206/// Must be registered before the second core's executor starts polling;
207/// unregistered, all hooks share core 0's stack (correct on single core).
208#[cfg(feature = "trace-nested")]
209pub fn set_core_id_fn(f: fn() -> usize) {
210    CORE_ID_FN.lock(|c| c.set(Some(f)));
211}
212
213/// The current core's stack index (0 when no fn is registered; clamped).
214#[cfg(feature = "trace-nested")]
215fn core_id() -> usize {
216    CORE_ID_FN
217        .lock(Cell::get)
218        .map_or(0, |f| f().min(MAX_CORES - 1))
219}
220
221#[cfg(feature = "trace-nested")]
222static NEST_DEPTH: [AtomicUsize; MAX_CORES] = {
223    #[allow(clippy::declare_interior_mutable_const)] // array initializer
224    const ZERO: AtomicUsize = AtomicUsize::new(0);
225    [ZERO; MAX_CORES]
226};
227#[cfg(feature = "trace-nested")]
228static NEST_STACK: [[AtomicUsize; MAX_EXECUTORS]; MAX_CORES] = {
229    #[allow(clippy::declare_interior_mutable_const)] // array initializer
230    const ZERO: AtomicUsize = AtomicUsize::new(0);
231    #[allow(clippy::declare_interior_mutable_const)]
232    const ROW: [AtomicUsize; MAX_EXECUTORS] = [ZERO; MAX_EXECUTORS];
233    [ROW; MAX_CORES]
234};
235
236static EXECUTORS: [ExecutorSlot; MAX_EXECUTORS] = [FREE_SLOT; MAX_EXECUTORS];
237
238/// Index of the slot matched by the most recent `slot_for` — the fast path for
239/// the overwhelmingly common case (one executor, or one hot executor firing
240/// most events). An index, not a pointer: reconstructing a reference from an
241/// `AtomicPtr` would need `unsafe`, which this crate forbids.
242static LAST_SLOT: AtomicUsize = AtomicUsize::new(0);
243
244/// Find (or claim) the slot for an executor id. Claiming races are settled by
245/// `compare_exchange` on the `id` field; a loser retries the scan once via the
246/// outer loop shape below (two passes are enough: either it finds the winner's
247/// slot or claims another).
248fn slot_for(executor_id: u32) -> Option<(usize, &'static ExecutorSlot)> {
249    // Fast path: the slot that matched last time (hooks fire thousands of times
250    // per second from at most a handful of executors).
251    let last = LAST_SLOT.load(Ordering::Relaxed);
252    if let Some(s) = EXECUTORS.get(last)
253        && s.id.load(Ordering::Acquire) == executor_id
254    {
255        return Some((last, s));
256    }
257    // Pass 1: existing slot.
258    for (i, s) in EXECUTORS.iter().enumerate() {
259        if s.id.load(Ordering::Acquire) == executor_id {
260            LAST_SLOT.store(i, Ordering::Relaxed);
261            return Some((i, s));
262        }
263    }
264    // Pass 2: claim a free one (or discover a racing claimer of the same id).
265    for (i, s) in EXECUTORS.iter().enumerate() {
266        match s
267            .id
268            .compare_exchange(0, executor_id, Ordering::AcqRel, Ordering::Acquire)
269        {
270            Ok(_) => {
271                LAST_SLOT.store(i, Ordering::Relaxed);
272                return Some((i, s));
273            }
274            Err(existing) if existing == executor_id => {
275                LAST_SLOT.store(i, Ordering::Relaxed);
276                return Some((i, s));
277            }
278            Err(_) => {}
279        }
280    }
281    None // table full: this executor's events are dropped
282}
283
284/// Current time in embassy-time ticks, truncated to u32 (wrapping arithmetic
285/// everywhere makes the truncation harmless for deltas).
286fn now_ticks() -> u32 {
287    embassy_time::Instant::now().as_ticks() as u32
288}
289
290// ─── Recorders ───────────────────────────────────────────────────────────
291//
292// The `trace-hooks` symbols below forward here; an application defining its own
293// hook symbols calls these directly. Everything is lock-free and safe from
294// interrupt context.
295
296/// Record a `poll_start` event: counts the scheduler pass — nothing else, by
297/// design. An open idle window is closed lazily by the first `exec_begin` of the
298/// pass (reusing the timestamp that hook takes anyway), so an **empty pass** —
299/// the executor woken with nothing runnable — costs no timer read here and
300/// merges into the surrounding idle window instead of inflating "overhead" with
301/// the instrument's own cost. An empty pass is ~100 ns uninstrumented, so
302/// timestamping it would make this hook the dominant cost of a wakeup; that
303/// holds however rare empty passes are, and they are rare only when nothing in
304/// the idle loop defeats `WFE` (see below).
305///
306/// # Why this is a load/store and not a `fetch_add`
307///
308/// This hook runs on **every** scheduler pass, including empty ones, so it sits
309/// squarely in the executor's idle path. On RP2350 a read-modify-write compiles
310/// to `ldaex`/`stlex`, and any exclusive access posts a global-monitor event
311/// that acts as an effective `SEV`, making the `WFE` at the bottom of the idle
312/// loop return immediately; `ldr`/`str` leaves the monitor alone.
313///
314/// Sound because `passes` has exactly one writer: only the executor owning
315/// `executor_id` calls this hook for its own slot, and a preempting
316/// interrupt-priority tier has a different id and so a different slot. Readers
317/// only load. Do not "fix" this back into a `fetch_add`.
318pub fn on_poll_start(executor_id: u32) {
319    let Some((_, slot)) = slot_for(executor_id) else {
320        return;
321    };
322    let passes = slot.passes.load(Ordering::Relaxed);
323    slot.passes.store(passes.wrapping_add(1), Ordering::Relaxed);
324}
325
326/// Record a task poll starting (`task_exec_begin`). Also closes an open idle
327/// window (see [`on_poll_start`]) with the same timestamp — a real poll pays for
328/// exactly one timer read here.
329pub fn on_task_exec_begin(executor_id: u32, task_id: u32) {
330    let Some((idx, slot)) = slot_for(executor_id) else {
331        return;
332    };
333    let now = now_ticks();
334    if slot.idle.swap(false, Ordering::AcqRel) {
335        let idled = now.wrapping_sub(slot.idle_since.load(Ordering::Acquire));
336        slot.idle_ticks.fetch_add(idled, Ordering::Relaxed);
337    }
338    slot.current_begin.store(now, Ordering::Relaxed);
339    slot.current_task.store(task_id, Ordering::Release);
340    // Open a frame on this core's preemption stack so a poll we preempted can
341    // be relieved of our wall time at our exec_end.
342    #[cfg(feature = "trace-nested")]
343    {
344        let core = core_id();
345        let depth = NEST_DEPTH[core].fetch_add(1, Ordering::Relaxed);
346        if let Some(frame) = NEST_STACK[core].get(depth) {
347            frame.store(idx, Ordering::Relaxed);
348        }
349    }
350    #[cfg(not(feature = "trace-nested"))]
351    let _ = idx;
352}
353
354/// Record a task poll ending (`task_exec_end`): attributes the elapsed ticks to
355/// the node mapped to `task_id` (unknown ids are counted nowhere and ignored).
356pub fn on_task_exec_end(executor_id: u32, task_id: u32) {
357    let Some((_, slot)) = slot_for(executor_id) else {
358        return;
359    };
360    let begin = slot.current_begin.load(Ordering::Relaxed);
361    slot.current_task.store(0, Ordering::Release);
362    // Raw wall time of this window; with `trace-nested` the time stolen by
363    // nested higher-tier polls is subtracted before attribution, and the full
364    // wall time is credited back to the window WE preempted (if any).
365    let raw = now_ticks().wrapping_sub(begin);
366    #[cfg(feature = "trace-nested")]
367    let elapsed = {
368        let stolen = slot.stolen_ticks.swap(0, Ordering::Relaxed);
369        // Pop our frame from this core's stack; the new top (if any) is the
370        // poll we preempted on this core. Guard depth 0: an unpaired end is
371        // possible (the matching `exec_begin` early-returned because the
372        // executor registered mid-poll), and an unguarded fetch_sub would
373        // wrap to usize::MAX, permanently desyncing attribution on this core.
374        // Plain load/store, no CAS: begin/end hooks for one core run on that
375        // core, never concurrently with each other.
376        let core = core_id();
377        let cur = NEST_DEPTH[core].load(Ordering::Relaxed);
378        let depth = cur.saturating_sub(1);
379        if cur > 0 {
380            NEST_DEPTH[core].store(depth, Ordering::Relaxed);
381        }
382        if depth > 0
383            && let Some(frame) = NEST_STACK[core].get(depth - 1)
384        {
385            let parent = frame.load(Ordering::Relaxed);
386            if let Some(p) = EXECUTORS.get(parent) {
387                p.stolen_ticks.fetch_add(raw, Ordering::Relaxed);
388            }
389        }
390        // Saturating, not wrapping: `raw` and `stolen` are plain magnitudes
391        // (already-diffed), and a stale epilogue-race deposit must clamp to a
392        // zero-length poll instead of underflowing (see the module comment).
393        raw.saturating_sub(stolen)
394    };
395    #[cfg(not(feature = "trace-nested"))]
396    let elapsed = raw;
397    // Executor-level accounting counts EVERY poll, resolvable or not, so that
398    // `busy - exec` isolates pure executor overhead and `exec - sum(nodes)` the
399    // unsupervised-task share (see `ExecutorStats`).
400    slot.exec_ticks.fetch_add(elapsed, Ordering::Relaxed);
401    slot.polls.fetch_add(1, Ordering::Relaxed);
402    if let Some(node) = node_for(task_id) {
403        node.handle.exec_ticks.fetch_add(elapsed, Ordering::Relaxed);
404        node.handle.polls.fetch_add(1, Ordering::Relaxed);
405        node.handle
406            .max_poll_ticks
407            .fetch_max(elapsed, Ordering::Relaxed);
408    }
409}
410
411/// Record the executor going idle (`executor_idle`): opens an idle window —
412/// unless one is already open (an empty pass, whose window was never closed), in
413/// which case the original window simply keeps running: no timer read, no store.
414/// Hooks of one executor never race each other (they fire from that executor's
415/// own context), so the load-then-store is not a lost-update hazard; the
416/// `idle_since`-before-`idle` order keeps readers ([`executor_stats`]) safe.
417pub fn on_executor_idle(executor_id: u32) {
418    let Some((_, slot)) = slot_for(executor_id) else {
419        return;
420    };
421    if !slot.idle.load(Ordering::Acquire) {
422        slot.idle_since.store(now_ticks(), Ordering::Relaxed);
423        slot.idle.store(true, Ordering::Release);
424    }
425}
426
427/// Record a task ending for good (`task_end`, i.e. its future completed and the
428/// storage is being released): clears the node's task-id mapping so a stale id
429/// can't be matched by a later, unrelated task reusing the storage.
430pub fn on_task_end(_executor_id: u32, task_id: u32) {
431    if let Some(node) = node_for(task_id) {
432        // Only clear if it still holds this id (a respawn may have overwritten it).
433        let _ =
434            node.handle
435                .task_id
436                .compare_exchange(task_id, 0, Ordering::AcqRel, Ordering::Acquire);
437    }
438}
439
440// ─── Read API ────────────────────────────────────────────────────────────
441
442/// A snapshot of one executor's accounting. All fields are wrapping u32 tick /
443/// event counters — sample twice and `wrapping_sub` for rates. The decomposition
444/// over a sampling window of `dt` ticks:
445///
446/// ```text
447/// busy      = dt - Δidle_ticks          (executor not sleeping)
448/// in-poll   = Δexec_ticks               (inside task polls, supervised or not)
449/// overhead  = busy - Δexec_ticks        (executor bookkeeping + trace-hook cost
450///                                        + ISR time landing between polls)
451/// unsupervised = Δexec_ticks - Σ Δnode.exec_ticks()   (task polls that resolve
452///                                        to no supervised node)
453/// ```
454///
455/// Overhead is charged per pass and per poll (dominated by the O(N ≤ 256) id scan
456/// in the hooks), so it scales with both rates. Measured on the demo firmware
457/// (RP2350, 8 nodes, `trace-hooks` + `trace-nested`, HTTP load): **0.7–1.0% of
458/// wall time run-to-run at ~1.9k polls/s and ~3.5k passes/s**, so under ~5 µs per
459/// poll, and that bound also absorbs the inter-poll ISR time folded into this
460/// term. Scale it by your own node count and rates rather than reusing the number.
461///
462/// **Empty scheduler passes count as idle**, not overhead: the idle window stays
463/// open across a pass that polls nothing (see [`on_poll_start`]), because such a
464/// wakeup is ~100 ns uninstrumented and timestamping it would make the trace
465/// hooks themselves the dominant "overhead". `Δpasses` vs `Δpolls` still shows
466/// the empty-wakeup rate explicitly.
467#[derive(Clone, Copy, Debug, Default)]
468pub struct ExecutorStats {
469    /// Accumulated idle ticks (includes a currently-open idle window, so a
470    /// sleeping executor doesn't read as busy between samples).
471    pub idle_ticks: u32,
472    /// Accumulated in-poll ticks across ALL task polls on this executor.
473    pub exec_ticks: u32,
474    /// Task polls (supervised or not).
475    pub polls: u32,
476    /// Scheduler passes (`poll_start` events); `polls / passes` = polls per pass.
477    pub passes: u32,
478}
479
480/// Snapshot an executor's accounting. Returns `None` for an untracked id.
481pub fn executor_stats(executor_id: u32) -> Option<ExecutorStats> {
482    for s in &EXECUTORS {
483        if s.id.load(Ordering::Acquire) == executor_id {
484            let mut idle = s.idle_ticks.load(Ordering::Relaxed);
485            // Include the currently-open idle window so a mostly-idle executor
486            // doesn't read as 0% idle between polls.
487            if s.idle.load(Ordering::Acquire) {
488                idle = idle
489                    .wrapping_add(now_ticks().wrapping_sub(s.idle_since.load(Ordering::Relaxed)));
490            }
491            return Some(ExecutorStats {
492                idle_ticks: idle,
493                exec_ticks: s.exec_ticks.load(Ordering::Relaxed),
494                polls: s.polls.load(Ordering::Relaxed),
495                passes: s.passes.load(Ordering::Relaxed),
496            });
497        }
498    }
499    None
500}
501
502/// Accumulated idle ticks of an executor (wrapping; sample twice for a rate).
503/// Returns 0 for an untracked executor id. Shorthand for
504/// [`executor_stats`]`.idle_ticks`.
505pub fn executor_idle_ticks(executor_id: u32) -> u32 {
506    executor_stats(executor_id).unwrap_or_default().idle_ticks
507}
508
509/// The executor ids currently tracked (`0` = free slot).
510pub fn executors() -> [u32; MAX_EXECUTORS] {
511    let mut ids = [0u32; MAX_EXECUTORS];
512    for (id, s) in ids.iter_mut().zip(&EXECUTORS) {
513        *id = s.id.load(Ordering::Acquire);
514    }
515    ids
516}
517
518/// The node currently being polled by an executor, with how long the poll has
519/// been running (ticks). `None` when the executor is idle/between polls, isn't
520/// tracked, or the in-flight task isn't a supervised node.
521///
522/// This is the raw "who is in-flight" primitive behind [`stalled_task`]. Note the
523/// single-executor blind spot: a task blocking *this* executor also blocks any
524/// observer task on it — run the observer on another (e.g. interrupt-priority)
525/// executor, or check from a pre-watchdog-reset path.
526pub fn current_task(executor_id: u32) -> Option<(&'static TaskNode, u32)> {
527    for s in &EXECUTORS {
528        if s.id.load(Ordering::Acquire) == executor_id {
529            let task_id = s.current_task.load(Ordering::Acquire);
530            if task_id == 0 {
531                return None;
532            }
533            let running = now_ticks().wrapping_sub(s.current_begin.load(Ordering::Relaxed));
534            return node_for(task_id).map(|n| (n, running));
535        }
536    }
537    None
538}
539
540/// Blocked-task detector: the node whose current poll has exceeded
541/// `threshold_ticks`, if any. A poll is expected to take microseconds; one
542/// running for, say, >100 ms means the task is busy-looping or computing without
543/// an await point and is starving its executor. See [`current_task`] for where
544/// this can meaningfully be called from; [`TaskNode::max_poll_ticks`] gives the
545/// same information post-hoc without an observer.
546pub fn stalled_task(executor_id: u32, threshold_ticks: u32) -> Option<(&'static TaskNode, u32)> {
547    current_task(executor_id).filter(|(_, running)| *running >= threshold_ticks)
548}
549
550// NOTE on the hook symbols: embassy-executor declares the `_embassy_trace_*`
551// hooks as `unsafe extern "Rust"`, so a definition requires `#[unsafe(no_mangle)]`
552// — which this crate cannot contain (`#![forbid(unsafe_code)]`, a published
553// guarantee). The definitions are therefore emitted by `supervisor_graph!` into
554// the APPLICATION crate under the `trace-hooks` feature (one graph declaration,
555// one hook set), forwarding to the recorder fns above. An application defining
556// its own hooks enables only `trace` and forwards manually.