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.