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.