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