Skip to main content

taktora_executor/
executor.rs

1//! `Executor` and `ExecutorBuilder`. Run loop lives in Task 8.
2
3// Fields consumed by the run loop (Task 8) and graph scheduler (Task 14).
4#![allow(dead_code)]
5// pub(crate) inside a private module — intentional, Task 8+ will use them.
6#![allow(clippy::redundant_pub_crate)]
7
8use crate::Channel;
9use crate::clock::{MonotonicClock, SystemClock};
10use crate::context::Stoppable;
11use crate::error::ExecutorError;
12use crate::fatal::{FatalDispatch, FatalHandler, FatalSite, guard_or_fatal, panic_payload_message};
13use crate::fault::{
14    ExecutorFaultAtomic, ExecutorFaultReason, ExecutorFaultState, FaultAtomic, FaultReason,
15    FaultState, duration_to_ms_sat, instant_to_since_ms,
16};
17use crate::item::ExecutableItem;
18use crate::monitor::{ExecutionMonitor, NoopMonitor};
19use crate::observer::{NoopObserver, Observer};
20use crate::payload::Payload;
21use crate::pool::Pool;
22use crate::stats::{CycleObservation, StatsSnapshot, TaskStatsEntry};
23use crate::task_id::TaskId;
24use crate::task_kind::TaskKind;
25use crate::thread_attrs::ThreadAttributes;
26use crate::trigger::{TriggerDecl, TriggerDeclarer};
27use core::sync::atomic::AtomicU32;
28use iceoryx2::node::Node;
29use iceoryx2::port::listener::Listener as IxListener;
30use iceoryx2::prelude::ipc;
31use iceoryx2::prelude::*;
32use iceoryx2::waitset::WaitSetRunResult;
33use std::sync::Arc;
34use std::sync::OnceLock;
35use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
36use std::time::{Duration, Instant};
37use taktora_stats::ExecutorCycleStats;
38
39/// Monotonically increasing counter so multiple executors in the same process
40/// each get a unique stop-event service name.
41static EXEC_COUNTER: AtomicU64 = AtomicU64::new(0);
42
43/// Executor histogram segment count (`S`) and exact-window length (`W`) for
44/// per-task cycle stats. Fixed at compile time per `ADR_0060`.
45pub(crate) type TaskCycleStats = ExecutorCycleStats<8, 256>;
46
47/// A single wakeup's pending cycle record, stashed on the [`TaskEntry`] between
48/// the pre-dispatch capture and the post-barrier fold. Bundling the pre-dispatch
49/// timestamp with its `faulted` flag in one `Option` makes them impossible to
50/// desync: a cycle is pending iff this is `Some`, and the `faulted` bit is then
51/// always the one captured at the same wakeup (`REQ_0107`).
52#[derive(Clone, Copy)]
53pub(crate) struct CyclePending {
54    /// Pre-dispatch timestamp for this wakeup (the cycle's `pre`), in
55    /// telemetry-clock nanoseconds (see [`MonotonicClock`]).
56    pub(crate) pre: u64,
57    /// `true` when this wakeup's scan was fault-routed/skipped, so the
58    /// post-barrier fold records it with `faulted=true`.
59    pub(crate) faulted: bool,
60}
61
62/// One registered task entry.
63pub(crate) struct TaskEntry {
64    /// Task identifier.
65    pub(crate) id: TaskId,
66    /// The kind of work this entry holds (single item or chain).
67    pub(crate) kind: TaskKind,
68    /// Trigger declarations recorded at `add` time.
69    pub(crate) decls: Vec<TriggerDecl>,
70    /// Pre-allocated dispatch closure. Built once at `add` / `add_chain`
71    /// time and re-invoked on every dispatch iteration via
72    /// `Pool::submit_borrowed`, avoiding the per-iteration `Box::new(closure)`
73    /// that `Pool::submit<F>` requires in threaded mode. Required for
74    /// `REQ_0060` (zero-alloc steady-state dispatch). `None` for
75    /// `TaskKind::Graph`, which dispatches its vertices via a separate
76    /// path and is handled by `REQ_0062` / `REQ_0063` follow-on work.
77    pub(crate) job: Option<Box<dyn FnMut() + Send + 'static>>,
78
79    /// Per-task budget declared via `TriggerDeclarer::budget`. `None`
80    /// means no per-task check; the executor-wide iteration budget
81    /// still applies. `REQ_0070`.
82    pub(crate) budget: Option<Duration>,
83
84    /// Per-task fault state. Wait-free read on the dispatch hot path.
85    /// Wrapped in `Arc` so dispatch closures built at `add` time can
86    /// capture an owning handle into the same atomic the `TaskEntry`
87    /// holds — `Arc::clone` is refcount-only, so this stays compatible
88    /// with `REQ_0060` (no per-iteration allocation). `REQ_0070`.
89    pub(crate) fault: Arc<FaultAtomic>,
90
91    /// Monotonic per-task overrun counter. Increments on EVERY budget
92    /// breach, including breaches while already `Faulted`. Never reset
93    /// by clearing the fault. Shared with the dispatch closure via
94    /// `Arc::clone`. `REQ_0102`.
95    pub(crate) overrun_count: Arc<AtomicU64>,
96
97    /// Pre-built dispatch closure for the fault-handler item. Mirrors
98    /// `job`. `None` means no handler — the task is simply skipped
99    /// during fault. `REQ_0072`.
100    pub(crate) handler_job: Option<Box<dyn FnMut() + Send + 'static>>,
101
102    /// Declared scan period for cyclic tasks (the `TriggerDecl::Interval`
103    /// duration), or `None` for event-driven tasks. Cached at add time so the
104    /// dispatch loop reads it without scanning `decls` per cycle. Gates cycle
105    /// telemetry: only cyclic tasks participate (`REQ_0106`).
106    pub(crate) scan_period: Option<Duration>,
107    /// Last-cycle execute duration in ns, written by the dispatch closure on
108    /// the pool worker and read by the `WaitSet` thread after `barrier()`.
109    /// Shared via `Arc` exactly like `overrun_count`. Sentinel `u64::MAX` =
110    /// "no sample this cycle" (the closure never ran — e.g. a faulted scan).
111    pub(crate) last_took_ns: Arc<AtomicU64>,
112
113    /// WaitSet-thread-only timestamp of this task's previous dispatch, for
114    /// computing `actual_period` (`REQ_0101`). Not shared (no atomic) — only the
115    /// single dispatch thread touches it. `None` before the first dispatch.
116    /// Telemetry-clock nanoseconds (see [`MonotonicClock`]).
117    pub(crate) last_dispatch: Option<u64>,
118
119    /// WaitSet-thread-only lateness grid slot for this task (`REQ_0106`).
120    /// Advances by exactly **one per scan attempt plus the dispatcher's
121    /// skipped-slot signal** (`REQ_0840`) — never reconstructed from the
122    /// noisy measured period, which over-counts on coalesced catch-up wakes
123    /// (issue #46 / `ADR_0101`). Starts at `0` (the first recorded cycle is
124    /// the task's own grid origin).
125    pub(crate) grid_slot: u64,
126
127    /// WaitSet-thread-only per-task lateness grid epoch (`REQ_0106`): the
128    /// task's first recorded `pre` — including a faulted first scan, whose
129    /// dispatch instant is real — back-dated by the dispatcher's `late_by`
130    /// signal when one is present (`Grid` mode), so the grid anchors at the
131    /// first dispatch's NOMINAL slot and a late process start is reported as
132    /// real first-cycle lateness instead of becoming a permanent negative
133    /// floor on every later on-grid cycle. Per-task (not executor-shared) so
134    /// a task's start phase never reads as permanent lateness.
135    pub(crate) grid_epoch: Option<u64>,
136
137    /// WaitSet-thread-only dispatcher skip signal for the *current* wakeup
138    /// (`REQ_0840`): written by the Grid dispatch pass before `dispatch_task`,
139    /// consumed (`take`n) by `record_cycle_for`. Never written in `Legacy`
140    /// mode or for event-driven tasks — stays `0`.
141    pub(crate) pending_skipped: u32,
142
143    /// WaitSet-thread-only dispatcher lateness signal for the *current*
144    /// wakeup (`REQ_0106`): how far past its nominal grid slot this dispatch
145    /// is, in scheduling-clock nanoseconds. Written by the Grid dispatch
146    /// pass, consumed (`take`n) by `record_cycle_for`, which uses the FIRST
147    /// one to anchor [`Self::grid_epoch`] on the nominal grid. `None` in
148    /// `Legacy` mode and for event-driven tasks (no dispatcher grid exists:
149    /// the epoch falls back to the first observed `pre`).
150    pub(crate) pending_late: Option<u64>,
151
152    /// WaitSet-thread-only stash of the *current* wakeup's pending cycle —
153    /// the pre-dispatch timestamp plus its `faulted` flag — carried across
154    /// `pool.barrier()` so the post-barrier record pass can fold this cycle's
155    /// telemetry without re-reading the clock or allocating a fired-index list.
156    /// `Some` between the pre-dispatch capture and the post-barrier
157    /// `record_cycle_for` `take`; `None` otherwise. Bundling the timestamp and
158    /// the fault flag in one `Option` keeps them from ever desyncing
159    /// (`REQ_0107`). Only the single dispatch thread touches it (no atomic).
160    pub(crate) pending_cycle: Option<CyclePending>,
161}
162
163/// Top-level executor. One per process is the typical case.
164pub struct Executor {
165    pub(crate) node: Node<ipc::Service>,
166    pub(crate) pool: Arc<Pool>,
167    pub(crate) tasks: Vec<TaskEntry>,
168    /// One cycle-stats aggregator per registered task, index-aligned with
169    /// `tasks`. Pushed at task-add time (before `run`), so no steady-state
170    /// allocation (`REQ_0060`, `REQ_0104`). Updated single-writer on the
171    /// `WaitSet` thread (Task 6).
172    pub(crate) cycle_stats: Vec<TaskCycleStats>,
173    /// Histogram sliding-window size in samples (`REQ_0100`).
174    pub(crate) stats_window: u32,
175    pub(crate) running: Arc<AtomicBool>,
176    pub(crate) stoppable: Stoppable,
177    pub(crate) next_id: AtomicU64,
178    /// Listener for the internal stop event service. Held here so it outlives
179    /// the `WaitSet` guard inside `dispatch_loop`. Created at `build()` time so
180    /// any `Stoppable` clone (taken before or after `run()`) carries the waker.
181    pub(crate) stop_listener: Arc<IxListener<ipc::Service>>,
182    /// Lifecycle observer. Defaults to a no-op.
183    pub(crate) observer: Arc<dyn Observer>,
184    /// Execution monitor. Defaults to a no-op.
185    pub(crate) monitor: Arc<dyn ExecutionMonitor>,
186    /// Per-iteration error capture slot — allocated once at build time and
187    /// reset to `None` at the top of each `dispatch_loop` iteration. Pool
188    /// workers obtain a refcount-only `Arc::clone` of this slot, avoiding
189    /// the per-iteration heap allocation that the previous design incurred.
190    /// Required for `REQ_0060`.
191    pub(crate) iter_err: Arc<std::sync::Mutex<Option<ExecutorError>>>,
192    /// Executor-wide iteration budget from `ExecutorBuilder::iteration_budget`.
193    /// `None` means no executor-wide check.
194    pub(crate) iteration_budget: Option<Duration>,
195    /// Executor-wide fault state. Wrapped in `Arc` so each dispatch
196    /// closure can hold an owning handle without re-borrowing through
197    /// `self`. `REQ_0071`.
198    pub(crate) exec_fault: Arc<ExecutorFaultAtomic>,
199
200    /// Index of the task whose `execute()` overran when the executor
201    /// transitioned to `Faulted`. Read alongside `exec_fault`.
202    pub(crate) exec_fault_task_idx: Arc<AtomicU32>,
203
204    /// Budget that was breached when the executor transitioned to
205    /// `Faulted`, in ms (saturated). Read alongside `exec_fault`.
206    pub(crate) exec_fault_budget_ms: Arc<AtomicU32>,
207
208    /// Executor start time, set on first dispatch. Used to compute
209    /// `since_ms` for faults relative to `Executor::run` entry. Wrapped
210    /// in `Arc` so dispatch closures share the same `OnceLock` with the
211    /// executor — `get_or_init` is idempotent and wait-free.
212    pub(crate) start_time: Arc<OnceLock<Instant>>,
213
214    /// Fatal-dispatch handle. Called once on the fail-fast path from the
215    /// executor-thread run-loop boundary; the pool holds a separate
216    /// `Arc::clone` for its own worker / inline-submit boundaries.
217    pub(crate) fatal_dispatch: Arc<FatalDispatch>,
218
219    /// Telemetry time source (`REQ_0101`/`REQ_0105`/`REQ_0106`). Read on the
220    /// worker (for `took`) and the `WaitSet` thread (for `pre`); defaults to
221    /// [`SystemClock`]. A test can substitute a [`MockClock`] via
222    /// [`ExecutorBuilder::clock`] for deterministic timing assertions. Affects
223    /// only telemetry — never scheduling or fault behaviour.
224    pub(crate) clock: Arc<dyn MonotonicClock>,
225
226    /// Cyclic dispatch timing strategy (`REQ_0268` / `ADR_0100`). Read once at
227    /// `dispatch_loop` entry and hoisted to a local, so steady-state cost is a
228    /// single `Copy`-enum compare per cycle. Defaults to
229    /// [`DispatchMode::Grid`](crate::DispatchMode).
230    pub(crate) dispatch_mode: crate::DispatchMode,
231
232    /// Scheduling time source for the absolute grid (`REQ_0268`). Distinct from
233    /// [`Executor::clock`] (telemetry): a telemetry mock can never alter
234    /// dispatch timing. Defaults to
235    /// [`MonotonicCyclicClock`](crate::MonotonicCyclicClock).
236    pub(crate) cyclic_clock: std::sync::Arc<dyn crate::CyclicClock>,
237}
238
239// SAFETY: `IxListener<ipc::Service>` is `!Send` for the same Rc-based
240// `SingleThreaded` reason as `IxNotifier`. After construction, the only
241// per-iteration call is `listener.try_wait_one()`, which does not mutate the
242// Rc. `Executor` is never shared across threads (it requires `&mut self` for
243// `run()`), so there is no aliased concurrent mutation.
244#[allow(unsafe_code, clippy::non_send_fields_in_send_ty)]
245unsafe impl Send for Executor {}
246
247impl Executor {
248    /// Start a new builder.
249    #[must_use]
250    pub fn builder() -> ExecutorBuilder {
251        ExecutorBuilder::default()
252    }
253
254    /// Open or create a pub/sub channel bound to this executor's node.
255    pub fn channel<T: Payload>(&mut self, name: &str) -> Result<Arc<Channel<T>>, ExecutorError> {
256        Channel::open_or_create(&self.node, name)
257    }
258
259    /// Open or create a request/response service bound to this executor's node.
260    pub fn service<Req, Resp>(
261        &mut self,
262        name: &str,
263    ) -> Result<Arc<crate::Service<Req, Resp>>, ExecutorError>
264    where
265        Req: Payload,
266        Resp: Payload,
267    {
268        crate::Service::open_or_create(&self.node, name)
269    }
270
271    /// Borrowed snapshot of every task's cycle aggregates (`REQ_0103` pull
272    /// path). Relaxed reads; never blocks the dispatch writer.
273    #[must_use]
274    pub fn stats_snapshot(&self) -> StatsSnapshot {
275        let per_task = self
276            .tasks
277            .iter()
278            .zip(self.cycle_stats.iter())
279            .map(|(t, s)| {
280                let snap = s.snapshot();
281                TaskStatsEntry {
282                    task_id: t.id.clone(),
283                    p50_ns: snap.p50_ns,
284                    p95_ns: snap.p95_ns,
285                    p99_ns: snap.p99_ns,
286                    min_ns: snap.min_ns,
287                    max_ns: snap.max_ns,
288                    max_jitter_ns: snap.max_jitter_ns,
289                    max_lateness_ns: snap.max_lateness_ns,
290                    overrun_count: t.overrun_count.load(Ordering::Acquire),
291                }
292            })
293            .collect();
294        StatsSnapshot { per_task }
295    }
296
297    /// Add an item to the executor with an auto-generated id.
298    pub fn add(&mut self, item: impl ExecutableItem) -> Result<TaskId, ExecutorError> {
299        let id = TaskId::new(format!(
300            "task-{}",
301            self.next_id.fetch_add(1, Ordering::SeqCst)
302        ));
303        self.add_with_id(id, item)
304    }
305
306    /// Add an item with a user-supplied id.
307    ///
308    /// The item's [`ExecutableItem::task_id`] override takes precedence over
309    /// the caller-supplied `id`, which itself takes precedence over the
310    /// auto-generated id assigned by [`Executor::add`].
311    pub fn add_with_id(
312        &mut self,
313        id: impl Into<TaskId>,
314        mut item: impl ExecutableItem,
315    ) -> Result<TaskId, ExecutorError> {
316        let id_arg: TaskId = id.into();
317        // The item's `task_id()` override wins over the user-supplied id.
318        let id = item.task_id().map_or(id_arg, TaskId::new);
319        let mut declarer = TriggerDeclarer::new_internal();
320        item.declare_triggers(&mut declarer)?;
321        let budget = declarer.budget;
322        let decls = declarer.into_decls();
323
324        // REQ_0268: reject ill-defined trigger shapes (cyclic+event, zero
325        // period) before the task joins the table — the natural validation
326        // point, where the decls are first available, for every DispatchMode.
327        validate_decls(&id, &decls)?;
328
329        let mut item_box: Box<dyn ExecutableItem> = Box::new(item);
330        let app_id = item_box.app_id();
331        let app_inst = item_box.app_instance_id();
332        // SAFETY: the raw pointer points into the heap allocation of
333        // `item_box`. `Box` keeps that allocation at a stable address even
334        // when the `Box` itself is moved (e.g. when `self.tasks` grows),
335        // so the pointer remains valid for the lifetime of the
336        // `TaskEntry`. See SendItemPtr safety doc for the rest of the
337        // discipline (barrier() pairs with worker access).
338        #[allow(unsafe_code)]
339        let item_ptr =
340            SendItemPtr::new(std::ptr::from_mut::<dyn ExecutableItem>(item_box.as_mut()));
341
342        // Allocate the per-task atomics now so the dispatch closure
343        // and the `TaskEntry` share the same `Arc` storage. The task
344        // will occupy `self.tasks.len()` after the push below — capture
345        // that index up front for `task_idx_u32`. Bounded workspace, so
346        // the `as u32` cast is sound; explicit allow keeps clippy quiet.
347        let task_fault = Arc::new(FaultAtomic::new());
348        let overrun_count = Arc::new(AtomicU64::new(0));
349        let scan_period = scan_period_from_decls(&decls);
350        let last_took_ns = Arc::new(AtomicU64::new(u64::MAX));
351        #[allow(clippy::cast_possible_truncation)]
352        let task_idx_u32 = self.tasks.len() as u32;
353        let fault_ctx = FaultDispatchCtx {
354            task_budget: budget,
355            task_fault: Arc::clone(&task_fault),
356            overrun_count: Arc::clone(&overrun_count),
357            iteration_budget: self.iteration_budget,
358            exec_fault: Arc::clone(&self.exec_fault),
359            exec_fault_task_idx: Arc::clone(&self.exec_fault_task_idx),
360            exec_fault_budget_ms: Arc::clone(&self.exec_fault_budget_ms),
361            task_idx_u32,
362            exec_start: Arc::clone(&self.start_time),
363            observer: Arc::clone(&self.observer),
364        };
365
366        let job = build_single_job(
367            id.clone(),
368            self.stoppable.clone(),
369            Arc::clone(&self.observer),
370            Arc::clone(&self.monitor),
371            Arc::clone(&self.iter_err),
372            app_id,
373            app_inst,
374            item_ptr,
375            fault_ctx,
376            Arc::clone(&last_took_ns),
377            Arc::clone(&self.clock),
378        );
379
380        self.tasks.push(TaskEntry {
381            id: id.clone(),
382            kind: TaskKind::Single(item_box),
383            decls,
384            job: Some(job),
385            budget,
386            fault: task_fault,
387            overrun_count,
388            handler_job: None,
389            scan_period,
390            last_took_ns: Arc::clone(&last_took_ns),
391            last_dispatch: None,
392            grid_slot: 0,
393            grid_epoch: None,
394            pending_skipped: 0,
395            pending_late: None,
396            pending_cycle: None,
397        });
398        self.cycle_stats
399            .push(TaskCycleStats::new(self.stats_window));
400        Ok(id)
401    }
402
403    /// Register an item plus a fault-handler item.
404    ///
405    /// The main item is registered through the canonical [`add`](Self::add)
406    /// path. The handler's [`declare_triggers`](ExecutableItem::declare_triggers)
407    /// is called (so handlers that internally rely on the declarer being
408    /// invoked observe the call) but its returned trigger list is
409    /// **ignored** — the handler dispatches on the main item's triggers
410    /// while the task is in `Faulted` state and runs in place of the main
411    /// item's `execute()`. The pre-built handler dispatch closure is
412    /// stashed on the same task entry as the main item's `job`,
413    /// satisfying `REQ_0072`.
414    ///
415    /// # Errors
416    ///
417    /// Propagates any error from registering the main item via `add`, or
418    /// from the handler's `declare_triggers` call.
419    ///
420    /// # Panics
421    ///
422    /// Panics if the task entry just inserted by [`add`](Self::add) cannot
423    /// be located in `self.tasks` — this is unreachable by construction
424    /// and indicates a logic bug.
425    pub fn add_with_fault_handler<I, H>(
426        &mut self,
427        main: I,
428        handler: H,
429    ) -> Result<TaskId, ExecutorError>
430    where
431        I: ExecutableItem,
432        H: ExecutableItem,
433    {
434        let task_id = self.add(main)?;
435
436        // Drain the handler's trigger declarations — they are ignored by
437        // design (the handler runs on the main item's triggers).
438        let mut handler_box: Box<dyn ExecutableItem> = Box::new(handler);
439        let mut throwaway = TriggerDeclarer::new_internal();
440        handler_box.declare_triggers(&mut throwaway)?;
441        drop(throwaway);
442
443        let app_id = handler_box.app_id();
444        let app_inst = handler_box.app_instance_id();
445
446        // Locate the task we just added so we can share its per-task
447        // atomics with the handler's `FaultDispatchCtx`. The handler
448        // runs on the same `TaskEntry`; per §4.6 invariant 5, a handler
449        // breach increments `overrun_count` and keeps state `Faulted`
450        // without re-firing the observer.
451        let task_idx = self
452            .tasks
453            .iter()
454            .position(|t| t.id == task_id)
455            .expect("just added; must exist");
456        let task = &self.tasks[task_idx];
457        #[allow(clippy::cast_possible_truncation)]
458        let task_idx_u32 = task_idx as u32;
459        let handler_fault_ctx = FaultDispatchCtx {
460            task_budget: task.budget,
461            task_fault: Arc::clone(&task.fault),
462            overrun_count: Arc::clone(&task.overrun_count),
463            iteration_budget: self.iteration_budget,
464            exec_fault: Arc::clone(&self.exec_fault),
465            exec_fault_task_idx: Arc::clone(&self.exec_fault_task_idx),
466            exec_fault_budget_ms: Arc::clone(&self.exec_fault_budget_ms),
467            task_idx_u32,
468            exec_start: Arc::clone(&self.start_time),
469            observer: Arc::clone(&self.observer),
470        };
471
472        let handler_closure = build_handler_job(
473            task_id.clone(),
474            self.stoppable.clone(),
475            Arc::clone(&self.observer),
476            Arc::clone(&self.monitor),
477            Arc::clone(&self.iter_err),
478            app_id,
479            app_inst,
480            handler_box,
481            handler_fault_ctx,
482        );
483
484        self.tasks[task_idx].handler_job = Some(handler_closure);
485
486        Ok(task_id)
487    }
488
489    /// Clear a per-task fault. Returns the previous `FaultState`.
490    /// Fires `Observer::on_task_clear` if the state changed from
491    /// `Faulted` to `Running`. `REQ_0070`.
492    ///
493    /// # Errors
494    ///
495    /// * [`ExecutorError::TaskNotFound`] if `task` is unknown.
496    /// * [`ExecutorError::TaskNotFaulted`] if `task` is already `Running`.
497    pub fn clear_task_fault(&self, task: TaskId) -> Result<FaultState, ExecutorError> {
498        let entry = self
499            .tasks
500            .iter()
501            .find(|t| t.id == task)
502            .ok_or_else(|| ExecutorError::TaskNotFound(task.clone()))?;
503        let budget_ms = entry.budget.map_or(0_u32, crate::fault::duration_to_ms_sat);
504        let prev = entry.fault.swap(FaultState::Running, budget_ms);
505        match prev {
506            FaultState::Running => Err(ExecutorError::TaskNotFaulted(task)),
507            FaultState::Faulted { .. } => {
508                self.observer.on_task_clear(task);
509                Ok(prev)
510            }
511        }
512    }
513
514    /// Clear the executor-wide fault and cascade-clear every task whose
515    /// state is `Faulted{ExecutorFaulted}`. Tasks whose state is
516    /// `Faulted{BudgetExceeded}` are NOT cleared (their own contract
517    /// breach is independent). Fires `Observer::on_executor_clear` and
518    /// one `Observer::on_task_clear` per cascade-cleared task.
519    /// `REQ_0071`.
520    ///
521    /// # Errors
522    ///
523    /// * [`ExecutorError::ExecutorNotFaulted`] if the executor is `Running`.
524    pub fn clear_executor_fault(&self) -> Result<ExecutorFaultState, ExecutorError> {
525        let task_idx = self.exec_fault_task_idx.load(Ordering::Acquire);
526        let budget_ms = self.exec_fault_budget_ms.load(Ordering::Acquire);
527        let prev = self
528            .exec_fault
529            .swap(ExecutorFaultState::Running, task_idx, budget_ms);
530        match prev {
531            ExecutorFaultState::Running => Err(ExecutorError::ExecutorNotFaulted),
532            ExecutorFaultState::Faulted { .. } => {
533                // Cascade-clear tasks whose reason is ExecutorFaulted.
534                for entry in &self.tasks {
535                    let task_budget_ms =
536                        entry.budget.map_or(0_u32, crate::fault::duration_to_ms_sat);
537                    if let FaultState::Faulted {
538                        reason: FaultReason::ExecutorFaulted,
539                        ..
540                    } = entry.fault.load(task_budget_ms)
541                    {
542                        let _ = entry.fault.swap(FaultState::Running, task_budget_ms);
543                        self.observer.on_task_clear(entry.id.clone());
544                    }
545                }
546                self.observer.on_executor_clear();
547                Ok(prev)
548            }
549        }
550    }
551
552    /// Return the per-task overrun counter — number of times the task's
553    /// `execute()` exceeded its budget over the executor's lifetime.
554    /// Monotonic; not reset by `clear_task_fault`. `REQ_0102`.
555    ///
556    /// # Errors
557    ///
558    /// * [`ExecutorError::TaskNotFound`] if `task` is unknown.
559    pub fn overrun_count(&self, task: TaskId) -> Result<u64, ExecutorError> {
560        self.tasks
561            .iter()
562            .find(|t| t.id == task)
563            .map(|t| t.overrun_count.load(Ordering::Acquire))
564            .ok_or_else(|| ExecutorError::TaskNotFound(task))
565    }
566
567    /// Return a snapshot of the per-task `FaultState`. `REQ_0073` (pull path).
568    ///
569    /// # Errors
570    ///
571    /// * [`ExecutorError::TaskNotFound`] if `task` is unknown.
572    pub fn task_fault_state(&self, task: TaskId) -> Result<FaultState, ExecutorError> {
573        self.tasks
574            .iter()
575            .find(|t| t.id == task)
576            .map(|t| {
577                let budget_ms = t.budget.map_or(0_u32, crate::fault::duration_to_ms_sat);
578                t.fault.load(budget_ms)
579            })
580            .ok_or_else(|| ExecutorError::TaskNotFound(task))
581    }
582
583    /// Return a snapshot of the executor-wide `ExecutorFaultState`.
584    /// `REQ_0073` (pull path).
585    #[must_use]
586    pub fn executor_fault_state(&self) -> ExecutorFaultState {
587        let task_idx = self.exec_fault_task_idx.load(Ordering::Acquire);
588        let budget_ms = self.exec_fault_budget_ms.load(Ordering::Acquire);
589        self.exec_fault.load(task_idx, budget_ms)
590    }
591
592    /// Add a sequential chain of items. Only the head item's
593    /// `declare_triggers` is consulted; non-head triggers are ignored with a
594    /// tracing warn.
595    pub fn add_chain<I, C>(&mut self, items: C) -> Result<TaskId, ExecutorError>
596    where
597        I: ExecutableItem,
598        C: IntoIterator<Item = I>,
599    {
600        let id = TaskId::new(format!(
601            "chain-{}",
602            self.next_id.fetch_add(1, Ordering::SeqCst)
603        ));
604        let boxed: Vec<Box<dyn ExecutableItem>> = items
605            .into_iter()
606            .map(|i| Box::new(i) as Box<dyn ExecutableItem>)
607            .collect();
608        self.add_chain_with_id_boxed(id, boxed)
609    }
610
611    /// Like [`Executor::add_chain`] but with a user-supplied id.
612    pub fn add_chain_with_id<I, C>(
613        &mut self,
614        id: impl Into<TaskId>,
615        items: C,
616    ) -> Result<TaskId, ExecutorError>
617    where
618        I: ExecutableItem,
619        C: IntoIterator<Item = I>,
620    {
621        let boxed: Vec<Box<dyn ExecutableItem>> = items
622            .into_iter()
623            .map(|i| Box::new(i) as Box<dyn ExecutableItem>)
624            .collect();
625        self.add_chain_with_id_boxed(id.into(), boxed)
626    }
627
628    fn add_chain_with_id_boxed(
629        &mut self,
630        id: TaskId,
631        mut items: Vec<Box<dyn ExecutableItem>>,
632    ) -> Result<TaskId, ExecutorError> {
633        if items.is_empty() {
634            return Err(ExecutorError::Builder(
635                "chain must contain at least one item".into(),
636            ));
637        }
638
639        // Head item's `task_id()` override wins over the user-supplied id.
640        let id = items[0].task_id().map_or(id, TaskId::new);
641
642        // Head's triggers gate the chain.
643        let mut head_declarer = TriggerDeclarer::new_internal();
644        items[0].declare_triggers(&mut head_declarer)?;
645        let decls = head_declarer.into_decls();
646
647        // REQ_0268: same trigger-shape validation as the single-item path,
648        // applied to the head item's decls (which gate the whole chain).
649        validate_decls(&id, &decls)?;
650
651        // Warn if non-head items declared triggers (those will be ignored).
652        for (i, body) in items.iter_mut().enumerate().skip(1) {
653            let mut spurious = TriggerDeclarer::new_internal();
654            let _ = body.declare_triggers(&mut spurious);
655            if !spurious.is_empty() {
656                #[cfg(feature = "tracing")]
657                tracing::warn!(
658                    target: "taktora-executor",
659                    task = %id,
660                    position = i,
661                    "non-head chain item declared triggers; they will be ignored"
662                );
663                #[cfg(not(feature = "tracing"))]
664                {
665                    let _ = i;
666                }
667            }
668        }
669
670        let mut items = items;
671        // SAFETY: pointer into the chain's `items` Vec. The Vec lives
672        // inside `TaskKind::Chain` inside `TaskEntry`. The Vec's buffer
673        // is stable once `add_chain` returns — `self.tasks` may grow
674        // (moving the `Vec<Box<...>>` header itself), but the Vec's
675        // heap buffer is referenced via the header's data pointer and
676        // is unaffected by header moves. We never resize the chain Vec
677        // after this point. See SendChainPtr safety doc for the rest.
678        #[allow(unsafe_code)]
679        let chain_ptr = SendChainPtr::new(std::ptr::from_mut::<Vec<Box<dyn ExecutableItem>>>(
680            &mut items,
681        ));
682        // NB: the pointer above is to the local `items` Vec on the
683        // stack — it's invalid after the `push` below moves items into
684        // the TaskEntry. We rederive a stable pointer after the push.
685        // (See the rebuild step below.)
686        let _ = chain_ptr;
687
688        // Pre-allocate the per-task atomics so the chain's dispatch
689        // closure can capture clones of the same `Arc`s the `TaskEntry`
690        // holds. The chain occupies `self.tasks.len()` after the push.
691        let task_fault = Arc::new(FaultAtomic::new());
692        let overrun_count = Arc::new(AtomicU64::new(0));
693        let scan_period = scan_period_from_decls(&decls);
694        let last_took_ns = Arc::new(AtomicU64::new(u64::MAX));
695        #[allow(clippy::cast_possible_truncation)]
696        let task_idx_u32 = self.tasks.len() as u32;
697
698        self.tasks.push(TaskEntry {
699            id: id.clone(),
700            kind: TaskKind::Chain(items),
701            decls,
702            job: None, // populated in the rebuild step below
703            // TODO(post-Task-10): chain budgets carried separately; for now None.
704            budget: None,
705            fault: Arc::clone(&task_fault),
706            overrun_count: Arc::clone(&overrun_count),
707            handler_job: None,
708            scan_period,
709            last_took_ns: Arc::clone(&last_took_ns),
710            last_dispatch: None,
711            grid_slot: 0,
712            grid_epoch: None,
713            pending_skipped: 0,
714            pending_late: None,
715            pending_cycle: None,
716        });
717        self.cycle_stats
718            .push(TaskCycleStats::new(self.stats_window));
719
720        // After the push, the TaskEntry lives at a stable position in
721        // `self.tasks` for the duration of this `add_chain_with_id_boxed`
722        // call. Take a stable pointer to its chain Vec and build the
723        // dispatch closure. If `self.tasks` later grows, the Vec header
724        // inside the TaskEntry moves but the header's data pointer
725        // (which addresses the chain's heap buffer) does not — and the
726        // closure derefs that pointer per dispatch, so it re-reads the
727        // current heap address each time. Sound under the same
728        // discipline as `tasks_ptr` in dispatch_loop.
729        let task_idx = self.tasks.len() - 1;
730        let chain_vec_ptr: *mut Vec<Box<dyn ExecutableItem>> = match &mut self.tasks[task_idx].kind
731        {
732            TaskKind::Chain(v) => std::ptr::from_mut::<Vec<Box<dyn ExecutableItem>>>(v),
733            // The push above used TaskKind::Chain, so this arm is
734            // unreachable. Mark it explicitly to satisfy `match`.
735            _ => unreachable!("just-pushed task is TaskKind::Chain"),
736        };
737        #[allow(unsafe_code)]
738        let chain_ptr = SendChainPtr::new(chain_vec_ptr);
739        let fault_ctx = FaultDispatchCtx {
740            task_budget: None, // chain budgets are intentionally None for now
741            task_fault,
742            overrun_count,
743            iteration_budget: self.iteration_budget,
744            exec_fault: Arc::clone(&self.exec_fault),
745            exec_fault_task_idx: Arc::clone(&self.exec_fault_task_idx),
746            exec_fault_budget_ms: Arc::clone(&self.exec_fault_budget_ms),
747            task_idx_u32,
748            exec_start: Arc::clone(&self.start_time),
749            observer: Arc::clone(&self.observer),
750        };
751        let job = build_chain_job(
752            id.clone(),
753            self.stoppable.clone(),
754            Arc::clone(&self.observer),
755            Arc::clone(&self.monitor),
756            Arc::clone(&self.iter_err),
757            chain_ptr,
758            fault_ctx,
759            Arc::clone(&last_took_ns),
760            Arc::clone(&self.clock),
761        );
762        self.tasks[task_idx].job = Some(job);
763        Ok(id)
764    }
765
766    /// Returns a [`Stoppable`] handle that is waker-aware from the moment the
767    /// executor is built. Clone before calling `run()` — any clone taken at any
768    /// time will wake the `WaitSet` when `stop()` is called.
769    #[must_use]
770    pub fn stoppable(&self) -> Stoppable {
771        self.stoppable.clone()
772    }
773
774    /// Borrow the underlying iceoryx2 node (escape hatch for power users).
775    pub const fn iceoryx_node(&self) -> &Node<ipc::Service> {
776        &self.node
777    }
778
779    /// Begin building a graph. Call `.build()` on the returned builder to
780    /// register the graph as a task.
781    pub fn add_graph(&mut self) -> ExecutorGraphBuilder<'_> {
782        ExecutorGraphBuilder {
783            executor: self,
784            builder: crate::graph::GraphBuilder::new(),
785            custom_id: None,
786        }
787    }
788}
789
790/// Builder for [`Executor`].
791pub struct ExecutorBuilder {
792    worker_threads: Option<usize>,
793    observer: Option<Arc<dyn Observer>>,
794    monitor: Option<Arc<dyn ExecutionMonitor>>,
795    worker_attrs: ThreadAttributes,
796    /// Executor-wide iteration budget (`REQ_0071`). `None` means no
797    /// executor-wide check.
798    iteration_budget: Option<Duration>,
799    /// User-supplied fatal handler. `None` → resolved to a no-op `Arc` in
800    /// `build()`.
801    fatal_handler: Option<FatalHandler>,
802    /// Sliding-window size (samples) for cycle-stats aggregation
803    /// (`REQ_0100`). `None` → resolved to `1024` in `build()`.
804    stats_window: Option<u32>,
805    /// Telemetry time source. `None` → resolved to [`SystemClock`] in
806    /// `build()`. Override with a [`MockClock`](crate::MockClock) for
807    /// deterministic timing tests.
808    clock: Option<Arc<dyn MonotonicClock>>,
809    /// Cyclic dispatch timing strategy (`REQ_0268`). Default
810    /// [`DispatchMode::Grid`](crate::DispatchMode).
811    dispatch_mode: crate::DispatchMode,
812    /// Scheduling clock for the absolute grid. `None` → resolved to
813    /// [`MonotonicCyclicClock`](crate::MonotonicCyclicClock) in `build()`.
814    cyclic_clock: Option<std::sync::Arc<dyn crate::CyclicClock>>,
815}
816
817impl Default for ExecutorBuilder {
818    fn default() -> Self {
819        Self {
820            worker_threads: None,
821            observer: None,
822            monitor: None,
823            worker_attrs: ThreadAttributes::new(),
824            iteration_budget: None,
825            fatal_handler: None,
826            stats_window: None,
827            clock: None,
828            dispatch_mode: crate::DispatchMode::default(),
829            cyclic_clock: None,
830        }
831    }
832}
833
834impl ExecutorBuilder {
835    /// Number of worker threads. `0` → inline (no pool). Default → physical
836    /// cores.
837    #[must_use]
838    pub const fn worker_threads(mut self, n: usize) -> Self {
839        self.worker_threads = Some(n);
840        self
841    }
842
843    /// Attach a lifecycle observer. If not called, a no-op observer is used.
844    #[must_use]
845    pub fn observer(mut self, obs: Arc<dyn Observer>) -> Self {
846        self.observer = Some(obs);
847        self
848    }
849
850    /// Attach an execution monitor. If not called, a no-op monitor is used.
851    #[must_use]
852    pub fn monitor(mut self, mon: Arc<dyn ExecutionMonitor>) -> Self {
853        self.monitor = Some(mon);
854        self
855    }
856
857    /// Configure the executor-wide iteration budget. Any task whose
858    /// `execute()` exceeds `dur` transitions the executor to `Faulted`
859    /// (`REQ_0071`). Default: unset (no executor-wide check).
860    #[must_use]
861    pub const fn iteration_budget(mut self, dur: Duration) -> Self {
862        self.iteration_budget = Some(dur);
863        self
864    }
865
866    /// Sliding-window size (samples) for percentile / min-max / jitter /
867    /// lateness aggregation (`REQ_0100`). Default `1024`.
868    #[must_use]
869    pub const fn stats_window(mut self, samples: u32) -> Self {
870        self.stats_window = Some(samples);
871        self
872    }
873
874    /// Substitute the telemetry time source. Defaults to [`SystemClock`].
875    ///
876    /// Pass a [`MockClock`](crate::MockClock) clone to drive `took` / jitter /
877    /// lateness from scripted instants, making timing assertions exact and
878    /// independent of the host scheduler. The clock affects telemetry only —
879    /// scheduling, run-mode deadlines and fault detection always use the real
880    /// monotonic clock.
881    #[must_use]
882    pub fn clock(mut self, clock: Arc<dyn MonotonicClock>) -> Self {
883        self.clock = Some(clock);
884        self
885    }
886
887    /// Select cyclic dispatch timing (default `DispatchMode::Grid`). `Legacy` is
888    /// the pre-REQ_0268 `attach_interval` path, retained only until the Pi A/B.
889    #[must_use]
890    pub const fn dispatch_mode(mut self, mode: crate::DispatchMode) -> Self {
891        self.dispatch_mode = mode;
892        self
893    }
894
895    /// Override the scheduling clock (default `MonotonicCyclicClock`). Distinct
896    /// from `clock` (telemetry) — see `CyclicClock`.
897    #[must_use]
898    pub fn cyclic_clock(mut self, clock: std::sync::Arc<dyn crate::CyclicClock>) -> Self {
899        self.cyclic_clock = Some(clock);
900        self
901    }
902
903    /// Set thread attributes (name prefix, CPU affinity, scheduling priority)
904    /// for worker threads. Has no effect when `worker_threads` is `0` (inline
905    /// mode). Requires the `thread_attrs` feature for non-default settings.
906    #[must_use]
907    #[allow(clippy::missing_const_for_fn)]
908    pub fn worker_attrs(mut self, attrs: ThreadAttributes) -> Self {
909        self.worker_attrs = attrs;
910        self
911    }
912
913    /// Register a best-effort last-gasp handler invoked once on the fail-fast
914    /// path immediately before `std::process::abort()`.
915    ///
916    /// **Contract**: runs over known-unsound executor state — MUST NOT touch
917    /// executor internals; a panic inside the handler routes straight to
918    /// `abort()`.
919    ///
920    /// The handler is expected to be time-bounded (the caller's responsibility);
921    /// no runtime deadline is imposed.
922    ///
923    /// **Observer / monitor containment carve-out**: the panic containment
924    /// described in the executor documentation covers only a user item's
925    /// `execute()` call. Panics that originate in framework-invoked user
926    /// callbacks that run *outside* that inner catch — such as
927    /// [`Observer`](crate::Observer) methods (e.g. `on_app_error`,
928    /// `on_task_fault`) and [`ExecutionMonitor`](crate::ExecutionMonitor)
929    /// methods (e.g. `post_execute`) — escape to this fail-fast boundary and
930    /// cause `abort()`. Those callbacks must therefore be treated as
931    /// non-panicking by the implementor. See `REQ_0123`.
932    ///
933    /// If not called, a no-op handler is used and `abort()` is still reached
934    /// after any unrecoverable fault.
935    #[must_use]
936    pub fn on_fatal(
937        mut self,
938        handler: impl Fn(&crate::FatalContext) + Send + Sync + 'static,
939    ) -> Self {
940        self.fatal_handler = Some(Arc::new(handler));
941        self
942    }
943
944    /// Build the [`Executor`]. Creates a fresh iceoryx2 node and wires up the
945    /// internal stop-event service so that any `Stoppable` clone (taken before
946    /// or after `run()`) will wake the `WaitSet` when `stop()` is called.
947    ///
948    /// # Panics
949    ///
950    /// Panics if the internally-generated stop-event service name exceeds the
951    /// iceoryx2 service name length limit (this cannot happen under normal use
952    /// because the name is derived from the process id and a monotonic counter).
953    #[allow(clippy::arc_with_non_send_sync)] // see SAFETY on `impl Send for Executor`
954    #[track_caller]
955    pub fn build(self) -> Result<Executor, ExecutorError> {
956        let node = NodeBuilder::new()
957            .create::<ipc::Service>()
958            .map_err(ExecutorError::iceoryx2)?;
959
960        let n_workers = self.worker_threads.unwrap_or_else(num_cpus::get_physical);
961
962        // Resolve the fatal handler: use the user-supplied one or fall back to a no-op.
963        let fatal_handler: FatalHandler = self
964            .fatal_handler
965            .unwrap_or_else(|| Arc::new(|_ctx: &crate::FatalContext| {}));
966        let fatal_dispatch = Arc::new(FatalDispatch::new(fatal_handler));
967
968        let pool = Arc::new(Pool::new(
969            n_workers,
970            self.worker_attrs,
971            Arc::clone(&fatal_dispatch),
972        )?);
973
974        // Build the internal stop event service with a unique-per-process name
975        // so multiple executors in the same process don't collide.
976        let exec_seq = EXEC_COUNTER.fetch_add(1, Ordering::Relaxed);
977        let stop_topic = format!(
978            "taktora.exec.stop.{}.{exec_seq}.__taktora_event",
979            std::process::id()
980        );
981        let stop_event = node
982            .service_builder(&stop_topic.as_str().try_into().unwrap())
983            .event()
984            .open_or_create()
985            .map_err(ExecutorError::iceoryx2)?;
986
987        let stop_notifier = Arc::new(
988            stop_event
989                .notifier_builder()
990                .create()
991                .map_err(ExecutorError::iceoryx2)?,
992        );
993
994        // SAFETY: see module-level note; Arc<IxListener> is held here and only
995        // accessed on the executor thread.
996        let stop_listener = Arc::new(
997            stop_event
998                .listener_builder()
999                .create()
1000                .map_err(ExecutorError::iceoryx2)?,
1001        );
1002
1003        // Wire the notifier into the Stoppable so every clone is waker-aware
1004        // from the moment the executor is built.
1005        let stoppable = Stoppable::with_waker(stop_notifier);
1006
1007        let observer: Arc<dyn Observer> = self.observer.unwrap_or_else(|| Arc::new(NoopObserver));
1008
1009        let monitor: Arc<dyn ExecutionMonitor> =
1010            self.monitor.unwrap_or_else(|| Arc::new(NoopMonitor));
1011
1012        let clock: Arc<dyn MonotonicClock> =
1013            self.clock.unwrap_or_else(|| Arc::new(SystemClock::new()));
1014
1015        let cyclic_clock: std::sync::Arc<dyn crate::CyclicClock> = self
1016            .cyclic_clock
1017            .unwrap_or_else(|| std::sync::Arc::new(crate::MonotonicCyclicClock::new()));
1018
1019        let exec = Executor {
1020            node,
1021            pool,
1022            tasks: Vec::new(),
1023            cycle_stats: Vec::new(),
1024            stats_window: self.stats_window.unwrap_or(1024),
1025            running: Arc::new(AtomicBool::new(false)),
1026            stoppable,
1027            next_id: AtomicU64::new(0),
1028            stop_listener,
1029            observer,
1030            monitor,
1031            iter_err: Arc::new(std::sync::Mutex::new(None)),
1032            iteration_budget: self.iteration_budget,
1033            exec_fault: Arc::new(ExecutorFaultAtomic::new()),
1034            exec_fault_task_idx: Arc::new(AtomicU32::new(0)),
1035            exec_fault_budget_ms: Arc::new(AtomicU32::new(0)),
1036            start_time: Arc::new(OnceLock::new()),
1037            fatal_dispatch,
1038            clock,
1039            dispatch_mode: self.dispatch_mode,
1040            cyclic_clock,
1041        };
1042
1043        Ok(exec)
1044    }
1045}
1046
1047// ── Run loop ──────────────────────────────────────────────────────────────────
1048
1049impl Executor {
1050    /// Run the executor until [`Stoppable::stop`] is called or a task signals
1051    /// stop via [`crate::Context::stop_executor`].
1052    ///
1053    /// # Errors
1054    ///
1055    /// Returns the **first** [`ExecutorError`] surfaced during dispatch:
1056    ///
1057    /// * [`ExecutorError::Item`] if any item returns `Err` or panics.
1058    /// * [`ExecutorError::Iceoryx2`] if a `WaitSet` operation fails.
1059    /// * [`ExecutorError::AlreadyRunning`] if the executor is already running.
1060    ///
1061    /// If multiple items error in the same dispatch iteration, only the first
1062    /// is preserved; subsequent errors are discarded silently. To observe
1063    /// every error, attach an [`Observer`](crate::Observer) and read errors
1064    /// via [`Observer::on_app_error`](crate::Observer::on_app_error).
1065    pub fn run(&mut self) -> Result<(), ExecutorError> {
1066        self.run_inner(RunMode::Forever)
1067    }
1068
1069    /// Run for at most `max` wall-clock duration, then return.
1070    ///
1071    /// # Errors
1072    ///
1073    /// Returns the **first** [`ExecutorError`] surfaced during dispatch:
1074    ///
1075    /// * [`ExecutorError::Item`] if any item returns `Err` or panics.
1076    /// * [`ExecutorError::Iceoryx2`] if a `WaitSet` operation fails.
1077    /// * [`ExecutorError::AlreadyRunning`] if the executor is already running.
1078    ///
1079    /// If multiple items error in the same dispatch iteration, only the first
1080    /// is preserved; subsequent errors are discarded silently. To observe
1081    /// every error, attach an [`Observer`](crate::Observer) and read errors
1082    /// via [`Observer::on_app_error`](crate::Observer::on_app_error).
1083    pub fn run_for(&mut self, max: Duration) -> Result<(), ExecutorError> {
1084        self.run_inner(RunMode::Until(Instant::now() + max))
1085    }
1086
1087    /// Run until `n` full barrier-cycles (`WaitSet` wakeups) have completed.
1088    ///
1089    /// # Errors
1090    ///
1091    /// Returns the **first** [`ExecutorError`] surfaced during dispatch:
1092    ///
1093    /// * [`ExecutorError::Item`] if any item returns `Err` or panics.
1094    /// * [`ExecutorError::Iceoryx2`] if a `WaitSet` operation fails.
1095    /// * [`ExecutorError::AlreadyRunning`] if the executor is already running.
1096    ///
1097    /// If multiple items error in the same dispatch iteration, only the first
1098    /// is preserved; subsequent errors are discarded silently. To observe
1099    /// every error, attach an [`Observer`](crate::Observer) and read errors
1100    /// via [`Observer::on_app_error`](crate::Observer::on_app_error).
1101    pub fn run_n(&mut self, n: usize) -> Result<(), ExecutorError> {
1102        self.run_inner(RunMode::Iterations(n))
1103    }
1104
1105    /// Run until `predicate()` returns true. Checked after each `WaitSet`
1106    /// wakeup.
1107    ///
1108    /// # Errors
1109    ///
1110    /// Returns the **first** [`ExecutorError`] surfaced during dispatch:
1111    ///
1112    /// * [`ExecutorError::Item`] if any item returns `Err` or panics.
1113    /// * [`ExecutorError::Iceoryx2`] if a `WaitSet` operation fails.
1114    /// * [`ExecutorError::AlreadyRunning`] if the executor is already running.
1115    ///
1116    /// If multiple items error in the same dispatch iteration, only the first
1117    /// is preserved; subsequent errors are discarded silently. To observe
1118    /// every error, attach an [`Observer`](crate::Observer) and read errors
1119    /// via [`Observer::on_app_error`](crate::Observer::on_app_error).
1120    pub fn run_until<F: FnMut() -> bool>(&mut self, mut predicate: F) -> Result<(), ExecutorError> {
1121        self.run_inner(RunMode::Predicate(&mut predicate))
1122    }
1123}
1124
1125enum RunMode<'a> {
1126    Forever,
1127    Until(Instant),
1128    Iterations(usize),
1129    Predicate(&'a mut dyn FnMut() -> bool),
1130}
1131
1132impl Executor {
1133    fn run_inner(&mut self, mut mode: RunMode<'_>) -> Result<(), ExecutorError> {
1134        // NOTE: Once `Stoppable::stop()` has been called, `self.stoppable.is_stopped()`
1135        // remains true permanently. Calling `run()` again after a stop will return
1136        // promptly without doing any meaningful work (it blocks until the first
1137        // trigger fires, then immediately exits the dispatch loop). Task 10's
1138        // Runner accommodates this by treating an Executor as one-shot: each
1139        // Runner owns the Executor and consumes it.
1140        if self.running.swap(true, Ordering::SeqCst) {
1141            return Err(ExecutorError::AlreadyRunning);
1142        }
1143
1144        self.observer.on_executor_up();
1145        let result = self.dispatch_loop(&mut mode);
1146        match &result {
1147            Ok(()) => self.observer.on_executor_down(),
1148            Err(e) => self.observer.on_executor_error(e),
1149        }
1150
1151        self.running.store(false, Ordering::SeqCst);
1152        result
1153    }
1154
1155    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1156    #[allow(
1157        unsafe_code,
1158        clippy::too_many_lines,
1159        clippy::ref_as_ptr,
1160        clippy::borrow_as_ptr
1161    )]
1162    fn dispatch_loop(&mut self, mode: &mut RunMode<'_>) -> Result<(), ExecutorError> {
1163        // Clear the per-wake transient tokens on every TaskEntry, once per
1164        // `run_*` call (O(task_count), alloc-free). `pending_cycle` doubles as
1165        // the per-phase dispatch dedup token (REQ_0854, #93), and its intended lifetime is
1166        // a single `dispatch_loop` invocation — but nothing enforced that
1167        // across a `run_*` re-entry, since `Executor` is `&mut self` and
1168        // `self.tasks` persists. A mid-wake fatal bail (`break Ok(())`) that
1169        // landed after a mark but before the post-barrier fold `take`d the
1170        // token would leave it `Some`, and the next `dispatch_loop`'s
1171        // `dispatch_task` guard would silently swallow that task's first
1172        // dispatch. Sweeping here makes the token lifetime one invocation by
1173        // construction (closes #102). Cross-cycle telemetry state
1174        // (`grid_epoch` / `grid_slot` / `last_dispatch`) is deliberately left
1175        // untouched — clearing it would corrupt the lateness grid.
1176        for task in &mut self.tasks {
1177            task.pending_cycle = None;
1178            task.pending_skipped = 0;
1179            task.pending_late = None;
1180        }
1181
1182        // Tight timer slack for the dispatch thread (REQ_0274). SCHED_OTHER
1183        // threads inherit the kernel's 50 µs default, and `epoll_wait`
1184        // sleeps through `schedule_hrtimeout_range` WITH that slack: on the
1185        // Pi5 rig that cost 56 µs/cycle accumulated Legacy-dispatch drift
1186        // (5.5 µs/cycle at 1 µs slack — the residual is iceoryx2's
1187        // ms-rounded epoll timeout + wake latency). Real-time classes force
1188        // slack to 0, so this is a no-op under SCHED_FIFO and for the
1189        // Grid/timerfd path (fd readiness, not timeout, drives the wake);
1190        // it removes a 10× cyclic-precision regression for non-RT Legacy
1191        // deployments. Thread-local, never fails for valid args; an error
1192        // (exotic kernel) would only restore today's behavior, so the
1193        // return value is deliberately not checked.
1194        #[cfg(target_os = "linux")]
1195        // SAFETY: prctl(PR_SET_TIMERSLACK) only adjusts the calling
1196        // thread's timer slack; no memory is involved.
1197        unsafe {
1198            libc::prctl(libc::PR_SET_TIMERSLACK, 1_000u64, 0, 0, 0);
1199        }
1200
1201        let waitset: WaitSet<ipc::Service> = WaitSetBuilder::new()
1202            .create()
1203            .map_err(ExecutorError::iceoryx2)?;
1204
1205        // Keep Arc<RawListener> alive for at least as long as the WaitSet
1206        // guards — the guard borrows the listener via 'attachment lifetime.
1207        let mut listener_storage: Vec<Arc<crate::trigger::RawListener>> = Vec::new();
1208        // Guards must outlive the run loop.
1209        let mut guards: Vec<WaitSetGuard<'_, '_, ipc::Service>> = Vec::new();
1210        // Maps guard index → task index.
1211        let mut attachment_to_task: Vec<usize> = Vec::new();
1212
1213        // Hoist to a local for the hot loop — one Copy-enum compare per cycle,
1214        // never a field re-read (REQ_0268).
1215        let dispatch_mode = self.dispatch_mode;
1216
1217        // Cyclic tasks are dispatched by the master timer + GridTimer (REQ_0268),
1218        // not attached as individual WaitSet triggers. Cross-platform: only the
1219        // wake source differs (Task 3).
1220        let mut cyclic_task_indices: Vec<usize> = Vec::new();
1221        let mut cyclic_periods: Vec<u64> = Vec::new();
1222        let deadline_count = build_attachments(
1223            &waitset,
1224            &self.tasks,
1225            dispatch_mode,
1226            &mut listener_storage,
1227            &mut guards,
1228            &mut attachment_to_task,
1229            &mut cyclic_task_indices,
1230            &mut cyclic_periods,
1231        )?;
1232        // `deadline_count` is the number of attached `Deadline` guards; it sizes
1233        // the `AttachmentMap` id buckets so the lazy-learned Notification-form ids
1234        // (which a missed `Deadline` resolves to on its first miss) never realloc
1235        // in steady state. The map is built once, below, just after the guards are
1236        // final — `build_attachments` is the only populator of `guards` /
1237        // `attachment_to_task`, so nothing mutates them past this point.
1238        //
1239        // Borrow-check note: `attachment_map` aliases nothing in `self.tasks`; it
1240        // is a standalone local declared before the run `loop` and re-borrowed
1241        // `&mut` by each iteration's WaitSet callback. The callback's `resolve`
1242        // closure borrows `guards` / `attachment_to_task` *immutably* (via the
1243        // `DispatchPass` slice fields), while `map.resolve` holds `&mut map` — two
1244        // distinct objects, no aliasing, no raw pointer.
1245        let mut attachment_map = crate::attachment_map::AttachmentMap::build(
1246            &guards,
1247            &attachment_to_task,
1248            deadline_count,
1249        );
1250        // `cyclic_periods` is cloned, not moved, because Task 3 reads it again to
1251        // arm the single master timerfd; on non-Linux it is unused after this.
1252        let mut grid =
1253            crate::grid::GridTimer::new(self.cyclic_clock.now_nanos(), cyclic_periods.clone());
1254        let mut due_cyclic: Vec<(usize, u64, u64)> = Vec::new();
1255
1256        // Master cyclic timer (REQ_0268, Linux). ONE timerfd armed at the base
1257        // period (gcd of cyclic periods) drives the absolute grid; GridTimer
1258        // decides which tasks are due each tick. Declared above its own guard so
1259        // it drops AFTER the guard (detach before close → no EBADF). Must be
1260        // declared here, after `build_attachments` has filled `cyclic_periods`.
1261        #[cfg(target_os = "linux")]
1262        let master_timer: Option<crate::timerfd::TimerFd> = {
1263            let base = crate::grid::base_period(&cyclic_periods);
1264            if base == 0 {
1265                None
1266            } else {
1267                Some(
1268                    crate::timerfd::TimerFd::new(std::time::Duration::from_nanos(base)).map_err(
1269                        |e| {
1270                            ExecutorError::DeclareTriggers(format!(
1271                                "failed to arm master timerfd: {e}"
1272                            ))
1273                        },
1274                    )?,
1275                )
1276            }
1277        };
1278
1279        // Attach the master timer as a wake-only notification, held separately
1280        // (like the stop listener) so `process_attachment` never maps it to a
1281        // task. `_master_timer_guard` is declared immediately after `master_timer`
1282        // so on scope exit it drops FIRST — detaching the fd from the WaitSet's
1283        // epoll set — and `master_timer` drops SECOND, closing the fd. That
1284        // ordering is what prevents iceoryx2's `EPOLL_CTL_DEL` from hitting a
1285        // closed fd (EBADF). `master_timer`'s fd is referenced ONLY by this guard
1286        // (independent of `guards`/`listener_storage`, which own other fds), so
1287        // its drop position relative to those Vecs is immaterial.
1288        #[cfg(target_os = "linux")]
1289        #[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
1290        let _master_timer_guard = match &master_timer {
1291            // SAFETY: `master_timer` is a stack local that outlives this guard
1292            // (declared above it); the cast erases the borrow lifetime to the
1293            // attachment lifetime, sound by the same discipline as the stop
1294            // listener. Dropped before `master_timer` closes the fd.
1295            Some(tf) => Some(
1296                waitset
1297                    .attach_notification(unsafe { &*(tf as *const crate::timerfd::TimerFd) })
1298                    .map_err(ExecutorError::iceoryx2)?,
1299            ),
1300            None => None,
1301        };
1302
1303        // Attach the internal stop listener so the WaitSet wakes when
1304        // stop() is called. We hold `self.stop_listener` (Arc) in the Executor
1305        // struct which is valid for the lifetime of dispatch_loop. We use the
1306        // same raw-pointer-cast pattern as user listeners above.
1307        //
1308        // SAFETY: `self.stop_listener` is an Arc stored on `self`, which is
1309        // exclusively borrowed for the duration of `run_inner` (which calls
1310        // `dispatch_loop`). The listener is not freed while the guard is alive
1311        // because the Arc keeps it alive and `self` outlives this function.
1312        let stop_listener_ref: &IxListener<ipc::Service> =
1313            unsafe { &*(self.stop_listener.as_ref() as *const _) };
1314        let _stop_guard = waitset
1315            .attach_notification(stop_listener_ref)
1316            .map_err(ExecutorError::iceoryx2)?;
1317
1318        let iterations_done = AtomicUsize::new(0);
1319        let stop_flag = self.stoppable.clone();
1320
1321        loop {
1322            // Reset the pre-allocated per-iteration error slot (REQ_0060):
1323            // the slot is owned by `self.iter_err`, allocated once at build
1324            // time. Pool worker closures obtain a refcount-only clone of
1325            // the `Arc`; the slot itself is reused across iterations.
1326            #[allow(clippy::unwrap_used)]
1327            // fail-fast: poison unreachable — the lock is held only over an infallible Option insert/take, and any holder panic aborts the process before another thread observes it (ADR_0065)
1328            let mut iter_err_guard = self.iter_err.lock().unwrap();
1329            *iter_err_guard = None;
1330            drop(iter_err_guard);
1331
1332            // SAFETY: we capture &mut self.tasks via a raw pointer because
1333            // wait_and_process expects FnMut and Rust can't see the closure
1334            // outlives `self`. The discipline that makes this sound:
1335            //   1. The closure body on the executor thread is the *only* code that
1336            //      reads `tasks_ptr`. The pool jobs it submits hold borrowed
1337            //      `*mut dyn ExecutableItem` slices into individual TaskEntries,
1338            //      not into the Vec itself, so they don't race with the Vec.
1339            //   2. The single per-wake `barrier_and_record` in
1340            //      `run_grid_cyclic_pass_guarded` (which runs after the callback
1341            //      returns, plus the defensive `pool.barrier()` on the
1342            //      `break Ok(())` bail paths) ensures every submitted pool job has
1343            //      completed (and dropped its raw pointer) before the next wake.
1344            //      The next iteration of the `WaitSet` loop is therefore the sole
1345            //      user of `tasks_ptr` again.
1346            //   3. The Vec is never resized inside this loop (no `push` / `remove`
1347            //      after dispatch starts), so the underlying buffer addresses are
1348            //      stable for the lifetime of `dispatch_loop`.
1349            let tasks_ptr = &mut self.tasks as *mut Vec<TaskEntry>;
1350            // Take the cycle_stats raw pointer before borrowing `observer`, so
1351            // the &mut borrow is released first — same discipline as tasks_ptr.
1352            let cycle_stats_ptr = &mut self.cycle_stats as *mut Vec<TaskCycleStats>;
1353            let observer = &self.observer;
1354            let pool = &self.pool;
1355            // Refcount-only clone of the pre-allocated error slot. Pool jobs
1356            // need a `'static` handle, and an `Arc::clone` does not allocate.
1357            // The Single/Chain paths use the closure baked into `task.job`,
1358            // which already captured stable Arc clones at `add`-time; the
1359            // Graph path uses closures pre-built by `prepare_dispatch`. Only
1360            // the error-aggregation logic on the WaitSet thread still needs
1361            // the slot here.
1362            let iter_err_inner = Arc::clone(&self.iter_err);
1363            // Raw pointer to the stop listener for draining inside the callback.
1364            // SAFETY: same as stop_listener_ref above — the Arc is alive for
1365            // the lifetime of dispatch_loop.
1366            let stop_listener_ptr = self.stop_listener.as_ref() as *const IxListener<ipc::Service>;
1367            // Raw pointer to the executor-wide fault state. Same safety
1368            // discipline as `tasks_ptr`: `Executor` is alive for the
1369            // duration of `dispatch_loop`; the WaitSet callback is the
1370            // only reader. REQ_0071. `self.exec_fault` is
1371            // `Arc<ExecutorFaultAtomic>` — we deref once to obtain a
1372            // pointer to the inner `ExecutorFaultAtomic`.
1373            let exec_fault_ptr = &*self.exec_fault as *const ExecutorFaultAtomic;
1374            // Raw pointer to the executor start time. Used by the lazy
1375            // cascade below to compute `since_ms` on task transitions
1376            // triggered by an executor-wide fault.
1377            let exec_start_ptr = &*self.start_time as *const OnceLock<Instant>;
1378            // Telemetry clock. Same lifetime/aliasing discipline as the
1379            // pointers above: the Executor outlives the dispatch loop and the
1380            // WaitSet callback is the sole reader.
1381            let clock = &self.clock;
1382
1383            // Wrap the per-iteration dispatch body in the framework panic
1384            // boundary. A panic escaping here is *infrastructure* (the WaitSet
1385            // drive, pool submission/barrier, or dispatch wiring) — not a user
1386            // item panic, which is already caught and faulted inside
1387            // `run_item_catch_unwind`. On such a panic `guard_or_fatal` runs the
1388            // user fatal handler then aborts in production. Under a test
1389            // terminal it returns `None`, in which case we must NOT keep
1390            // iterating over possibly-corrupt executor state, so we break out.
1391            let Some(cb_result) =
1392                guard_or_fatal(&self.fatal_dispatch, FatalSite::ExecutorRunLoop, || {
1393                    // Bundle the per-iteration captures into a single context the
1394                    // WaitSet callback delegates to. Keeping the closure a thin
1395                    // adapter over `DispatchPass::process_attachment` keeps the
1396                    // dispatch logic in named, individually-measurable functions.
1397                    let mut pass = DispatchPass {
1398                        guards: &guards,
1399                        attachment_to_task: &attachment_to_task,
1400                        tasks_ptr,
1401                        cycle_stats_ptr,
1402                        observer,
1403                        exec_fault_ptr,
1404                        exec_start_ptr,
1405                        clock,
1406                        stop_listener_ptr,
1407                        pool,
1408                        iter_err: &iter_err_inner,
1409                    };
1410
1411                    // Linux: block on fds — the master timerfd wakes us on the
1412                    // absolute grid. Non-Linux dev: bound the wait by the earliest
1413                    // pending grid target so the post-wait pass can dispatch.
1414                    #[cfg(target_os = "linux")]
1415                    let timeout = std::time::Duration::MAX;
1416                    #[cfg(not(target_os = "linux"))]
1417                    let timeout = match dispatch_mode {
1418                        crate::DispatchMode::Grid => {
1419                            grid.next_timeout(self.cyclic_clock.now_nanos())
1420                        }
1421                        crate::DispatchMode::Legacy => std::time::Duration::MAX,
1422                    };
1423                    waitset.wait_and_process_once_with_timeout(
1424                        |attachment_id: WaitSetAttachmentId<ipc::Service>| {
1425                            // `attachment_map` is a standalone local re-borrowed
1426                            // `&mut` each iteration; it aliases nothing `pass`
1427                            // already borrows (`pass` holds the `guards` /
1428                            // `attachment_to_task` slices immutably), so the two
1429                            // live side by side without conflict.
1430                            pass.process_attachment(&attachment_id, &mut attachment_map)
1431                        },
1432                        timeout,
1433                    )
1434                })
1435            else {
1436                // Only reachable under a test terminal (production aborts in
1437                // `fire`). Bail out of the run loop rather than continuing over
1438                // possibly-corrupt executor state.
1439                //
1440                // Unreachable in production: the production terminal aborts
1441                // before returning, so this branch exists solely so a
1442                // `#[cfg(test)]` recording terminal can unwind the loop.
1443                // Consequently, silently discarding any pending `iter_err`
1444                // here is immaterial to production behavior.
1445                //
1446                // D4 (#95): defensive drain so no in-flight borrowed job
1447                // outlives `tasks_ptr` exclusivity at loop exit (production
1448                // aborts in `fire`; this covers the `#[cfg(test)]` terminal). A
1449                // second `barrier()` on a quiescent pool is a counter fast-path
1450                // no-op.
1451                pool.barrier();
1452                break Ok(());
1453            };
1454
1455            // Did the master timer tick this wake? Linux: drain it (clears epoll
1456            // readiness; >0 overruns means the absolute grid advanced). Non-Linux:
1457            // the self-computed timeout drove the wake, so always consult the grid
1458            // (take_due self-gates per task on `now >= next`). REQ_0268.
1459            #[cfg(target_os = "linux")]
1460            let ticked = master_timer.as_ref().is_some_and(|tf| tf.drain() > 0);
1461            #[cfg(not(target_os = "linux"))]
1462            let ticked = true;
1463
1464            // Post-wait master-grid pass (Grid mode). `run_grid_cyclic_pass`
1465            // self-gates on `ticked` / stop-wake / mode / non-empty, then
1466            // dispatches EVERY due cyclic task atomically this tick (PLC
1467            // semantics). `cpass` is a side-effect-free bundle of borrows, so
1468            // building it unconditionally is free; the gate lives in the helper
1469            // to keep `dispatch_loop` within the complexity budget. REQ_0268.
1470            let cpass = DispatchPass {
1471                guards: &guards,
1472                attachment_to_task: &attachment_to_task,
1473                tasks_ptr,
1474                cycle_stats_ptr,
1475                observer,
1476                exec_fault_ptr,
1477                exec_start_ptr,
1478                clock,
1479                stop_listener_ptr,
1480                pool,
1481                iter_err: &iter_err_inner,
1482            };
1483            // Route the grid pass through the SAME REQ_0123, #103 framework-fault
1484            // boundary as the wait above: its `barrier_and_record` ->
1485            // `record_cycle_for` -> `Observer::on_cycle_stats` fold runs a user
1486            // callback that can panic, and such a panic must reach
1487            // `fatal.fire(...)` -> abort, not unwind raw out of `dispatch_loop`.
1488            // `None` is only reachable under a test terminal (production aborts
1489            // in `fire`); bail the loop exactly like the wait boundary above
1490            // rather than iterate over possibly-corrupt state.
1491            let now_nanos = self.cyclic_clock.now_nanos();
1492            let Some(()) = run_grid_cyclic_pass_guarded(
1493                &self.fatal_dispatch,
1494                cpass,
1495                ticked,
1496                dispatch_mode,
1497                &stop_flag,
1498                cb_result,
1499                &mut grid,
1500                now_nanos,
1501                &cyclic_task_indices,
1502                &mut due_cyclic,
1503            ) else {
1504                // D4 (#95): defensive drain so no in-flight borrowed job
1505                // outlives `tasks_ptr` exclusivity at loop exit (production
1506                // aborts in `fire`; this covers the `#[cfg(test)]` terminal). A
1507                // second `barrier()` on a quiescent pool is a counter fast-path
1508                // no-op.
1509                pool.barrier();
1510                break Ok(());
1511            };
1512
1513            // Funnel the post-callback decision (interrupt / item error /
1514            // stop request / run-mode termination) through one helper that
1515            // yields a single control value, so the loop has exactly one exit.
1516            match self.after_callback(cb_result, mode, &iterations_done, &stop_flag) {
1517                IterOutcome::Continue => {}
1518                IterOutcome::Done => break Ok(()),
1519                IterOutcome::Failed(err) => break Err(err),
1520            }
1521        }
1522    }
1523
1524    /// Evaluates the post-callback termination conditions for one dispatch
1525    /// iteration and reports whether the loop should continue, stop, or fail.
1526    ///
1527    /// Order of precedence matches the original inline checks: `WaitSet`
1528    /// errors, then SIGINT/SIGTERM, then a captured item error, then a stop
1529    /// request, then a bare-EINTR continue (`REQ_0269` — an interrupted wait
1530    /// is a spurious wake, not a termination), then the active [`RunMode`]
1531    /// limit.
1532    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1533    fn after_callback(
1534        &self,
1535        cb_result: Result<WaitSetRunResult, iceoryx2::waitset::WaitSetRunError>,
1536        mode: &mut RunMode<'_>,
1537        iterations_done: &AtomicUsize,
1538        stop_flag: &Stoppable,
1539    ) -> IterOutcome {
1540        let cb_result = match cb_result.map_err(ExecutorError::iceoryx2) {
1541            Ok(r) => r,
1542            Err(e) => return IterOutcome::Failed(e),
1543        };
1544
1545        // iceoryx2's WaitSet catches SIGINT/SIGTERM internally and reports
1546        // them as `TerminationRequest`; honor that here for a clean exit.
1547        if matches!(cb_result, WaitSetRunResult::TerminationRequest) {
1548            return IterOutcome::Done;
1549        }
1550
1551        // `Interrupt` is a bare EINTR: ANY handled signal — job control
1552        // (SIGSTOP/SIGCONT), a debugger attach, a user-installed handler —
1553        // interrupts the reactor wait. That is a spurious wake, not a
1554        // termination request: a cyclic control runtime must keep running
1555        // (REQ_0269; found on the Pi5 rig, where `kill -STOP`/`-CONT` ended
1556        // `run_n(60000)` cleanly at ~5k cycles). Item errors and `stop()`
1557        // are still honored below; the wake is NOT counted as an iteration
1558        // (nothing was dispatched). A real SIGINT/SIGTERM still terminates:
1559        // iceoryx2 latches it and the NEXT wait call returns
1560        // `TerminationRequest` before blocking.
1561        let interrupted = matches!(cb_result, WaitSetRunResult::Interrupt);
1562
1563        // Extract the error before dropping the MutexGuard — avoids holding the
1564        // lock across the return (clippy::significant_drop_in_scrutinee).
1565        #[allow(clippy::unwrap_used)]
1566        // fail-fast: poison unreachable — the lock is held only over an infallible Option insert/take, and any holder panic aborts the process before another thread observes it (ADR_0065)
1567        let maybe_err = self.iter_err.lock().unwrap().take();
1568        if let Some(err) = maybe_err {
1569            return IterOutcome::Failed(err);
1570        }
1571        if stop_flag.is_stopped() {
1572            return IterOutcome::Done;
1573        }
1574
1575        // An interrupted wake is spurious (REQ_0269): it dispatched nothing,
1576        // so it neither counts as an iteration nor evaluates the run-mode
1577        // limit — it short-circuits to `Continue` and re-enters the wait.
1578        let reached_limit = !interrupted && {
1579            iterations_done.fetch_add(1, Ordering::SeqCst);
1580            match mode {
1581                RunMode::Forever => false,
1582                RunMode::Iterations(n) => iterations_done.load(Ordering::SeqCst) >= *n,
1583                RunMode::Until(deadline) => Instant::now() >= *deadline,
1584                RunMode::Predicate(p) => (p)(),
1585            }
1586        };
1587        if reached_limit {
1588            IterOutcome::Done
1589        } else {
1590            IterOutcome::Continue
1591        }
1592    }
1593}
1594
1595/// Outcome of one `dispatch_loop` iteration's post-callback evaluation.
1596enum IterOutcome {
1597    /// Run another iteration.
1598    Continue,
1599    /// Terminate the loop successfully.
1600    Done,
1601    /// Terminate the loop with the given error.
1602    Failed(ExecutorError),
1603}
1604
1605/// Post-wait absolute-grid pass (Grid mode only, `REQ_0268` / `ADR_0100`).
1606///
1607/// The `WaitSet` callback handles event/fd tasks; cyclic tasks are timed here
1608/// off the scheduling clock. `pass` mirrors the callback's `DispatchPass`
1609/// exactly — same borrows and raw pointers, same single-writer WaitSet-thread
1610/// discipline — and the callback is already dropped (its borrows freed) by the
1611/// time this runs. We poll `grid` for due cyclic slots and dispatch each due
1612/// task (marking its `pending_cycle`).
1613///
1614/// Dispatch only (#95): this no longer barriers or folds telemetry. The lone
1615/// per-wake `pool.barrier()` + telemetry fold moved to the caller
1616/// ([`run_grid_cyclic_pass_guarded`]), which runs the single
1617/// [`DispatchPass::barrier_and_record`] AFTER this returns — folding the event
1618/// tasks the `WaitSet` callback marked AND the grid cyclic tasks marked here in
1619/// one pass. We borrow `&mut pass` so the caller still owns the `DispatchPass`
1620/// value and can run that barrier on it. We do NOT call `record_cycle_for`
1621/// directly here.
1622///
1623/// Self-gates and returns early (no dispatch) unless this wake should run the
1624/// grid: the master timer ticked (`ticked`), we are in `Grid` mode, it is not a
1625/// stop wake, and there is at least one cyclic task with something due. The
1626/// caller's barrier is UNCONDITIONAL — it runs even on these early returns,
1627/// folding any event marks left by the callback.
1628///
1629/// **Stop-wake suppression (`REQ_0268`)**: a `stop()` (or a SIGINT/SIGTERM
1630/// `cb_result`) must emit no spurious cyclic cycle — Legacy dispatches none on a
1631/// stop wake, so the grid path matches, or a `stop()` would emit one extra cycle
1632/// observation and desync the `FEAT_0038` `cycle_index` join key. Termination
1633/// itself is still decided by `after_callback`; this only suppresses the side
1634/// effects.
1635#[allow(clippy::too_many_arguments)]
1636fn run_grid_cyclic_pass(
1637    pass: &mut DispatchPass<'_, '_, '_>,
1638    ticked: bool,
1639    dispatch_mode: crate::DispatchMode,
1640    stop_flag: &Stoppable,
1641    cb_result: Result<WaitSetRunResult, iceoryx2::waitset::WaitSetRunError>,
1642    grid: &mut crate::grid::GridTimer,
1643    now_nanos: u64,
1644    cyclic_task_indices: &[usize],
1645    due_cyclic: &mut Vec<(usize, u64, u64)>,
1646) {
1647    let stopping = stop_flag.is_stopped()
1648        || matches!(
1649            cb_result,
1650            Ok(WaitSetRunResult::Interrupt | WaitSetRunResult::TerminationRequest)
1651        );
1652    if !ticked
1653        || stopping
1654        || dispatch_mode != crate::DispatchMode::Grid
1655        || cyclic_task_indices.is_empty()
1656    {
1657        return;
1658    }
1659    grid.take_due(now_nanos, due_cyclic);
1660    if due_cyclic.is_empty() {
1661        return;
1662    }
1663    for (slot, skipped, late_by) in due_cyclic.iter() {
1664        pass.dispatch_cyclic(cyclic_task_indices[*slot], *skipped, *late_by);
1665    }
1666}
1667
1668/// Run [`run_grid_cyclic_pass`] AND the lone per-wake barrier+fold inside the
1669/// `REQ_0123` / #103 framework-fault boundary.
1670///
1671/// Two things happen here, both wrapped in the boundary: (1) the grid pass
1672/// dispatches due cyclic tasks (marking their `pending_cycle`), and (2) the ONE
1673/// per-wake [`DispatchPass::barrier_and_record`] runs — barriering every
1674/// in-flight pool job and folding every task (event OR cyclic) whose
1675/// `pending_cycle` is set this wake. The fold runs
1676/// `record_cycle_for` -> [`Observer::on_cycle_stats`], a *user* callback that
1677/// can panic; that panic is a framework-boundary fault (same class as the
1678/// wrapped `WaitSet` drive), so it must route to `fatal.fire(...)` -> abort,
1679/// not unwind raw out of `dispatch_loop`. `None` (only reachable under a
1680/// `#[cfg(test)]` recording terminal — production aborts in `fire`) signals the
1681/// caller to bail the loop rather than iterate over possibly-corrupt state.
1682/// `cb_result` is `Copy`, so passing it by value here leaves it intact for
1683/// `after_callback`.
1684#[allow(clippy::too_many_arguments)]
1685fn run_grid_cyclic_pass_guarded(
1686    fatal: &FatalDispatch,
1687    mut pass: DispatchPass<'_, '_, '_>,
1688    ticked: bool,
1689    dispatch_mode: crate::DispatchMode,
1690    stop_flag: &Stoppable,
1691    cb_result: Result<WaitSetRunResult, iceoryx2::waitset::WaitSetRunError>,
1692    grid: &mut crate::grid::GridTimer,
1693    now_nanos: u64,
1694    cyclic_task_indices: &[usize],
1695    due_cyclic: &mut Vec<(usize, u64, u64)>,
1696) -> Option<()> {
1697    guard_or_fatal(fatal, FatalSite::ExecutorRunLoop, || {
1698        run_grid_cyclic_pass(
1699            &mut pass,
1700            ticked,
1701            dispatch_mode,
1702            stop_flag,
1703            cb_result,
1704            grid,
1705            now_nanos,
1706            cyclic_task_indices,
1707            due_cyclic,
1708        );
1709        // D2/D3: the ONE barrier + fold per wake. Unconditional (runs even when
1710        // run_grid_cyclic_pass early-returns for Legacy/stop/not-ticked/no-cyclic),
1711        // REQ_0123-guarded (we are inside guard_or_fatal), and covers BOTH
1712        // populations: event tasks marked by the WaitSet callback AND grid cyclic
1713        // tasks marked just above. barrier_and_record folds every task index whose
1714        // pending_cycle is Some, so a different DispatchPass value having set the
1715        // event stashes is irrelevant — same tasks_ptr.
1716        pass.barrier_and_record();
1717    })
1718}
1719
1720/// Build every `WaitSet` attachment for the task table (`REQ_0268`). In `Grid`
1721/// mode, `TriggerDecl::Interval` cyclic tasks are only *collected* into
1722/// `cyclic_task_indices` / `cyclic_periods` — they are NOT attached as
1723/// individual `WaitSet` triggers. The master timer + `GridTimer` owns their
1724/// wakeups (cross-platform; wake-source wiring is done in the caller).
1725/// Every other decl (and every decl in `Legacy` mode, including `Interval`
1726/// via `attach_interval`) is attached normally. Extracted from `dispatch_loop`
1727/// to keep that function within the cyclomatic-complexity budget.
1728/// Today's linear resolution: returns the task index for the single guard the
1729/// fired id matches, or `IGNORE` if none. At most one guard can match (one fd
1730/// per attachment, unique tick indices), so first-match is exact.
1731///
1732/// Consumed by `process_attachment` via `AttachmentMap::resolve`; the
1733/// `O(log n)` map calls this as its `slow` fallback to seed the per-id cache.
1734/// Kept as a free fn (sibling of `attach_trigger_decl`) so both the map's
1735/// fallback and any direct caller share one definition.
1736fn linear_scan(
1737    guards: &[WaitSetGuard<'_, '_, ipc::Service>],
1738    attachment_to_task: &[usize],
1739    id: &WaitSetAttachmentId<ipc::Service>,
1740) -> usize {
1741    let mut found = crate::attachment_map::IGNORE;
1742    for i in 0..guards.len() {
1743        if id.has_event_from(&guards[i]) || id.has_missed_deadline(&guards[i]) {
1744            // Debug: keep scanning to assert no second guard matches.
1745            // Release: first match is exact (uniqueness invariant), stop early.
1746            //
1747            // `cfg!(debug_assertions)` is a RUNTIME bool, not a `#[cfg]`
1748            // attribute, so BOTH arms always compile — the Linux clippy run
1749            // still sees the early `return`. Deliberately NOT `#[cfg(...)]`,
1750            // which would gate a path the macOS clippy run skips.
1751            if cfg!(debug_assertions) {
1752                debug_assert_eq!(
1753                    found,
1754                    crate::attachment_map::IGNORE,
1755                    "id matched two guards"
1756                );
1757                found = attachment_to_task[i];
1758            } else {
1759                return attachment_to_task[i];
1760            }
1761        }
1762    }
1763    found
1764}
1765
1766#[allow(clippy::too_many_arguments)]
1767fn build_attachments<'w>(
1768    waitset: &'w WaitSet<ipc::Service>,
1769    tasks: &[TaskEntry],
1770    dispatch_mode: crate::DispatchMode,
1771    listener_storage: &mut Vec<Arc<crate::trigger::RawListener>>,
1772    guards: &mut Vec<WaitSetGuard<'w, 'static, ipc::Service>>,
1773    attachment_to_task: &mut Vec<usize>,
1774    cyclic_task_indices: &mut Vec<usize>,
1775    cyclic_periods: &mut Vec<u64>,
1776) -> Result<usize, ExecutorError> {
1777    // Number of `Deadline` decls actually ATTACHED as guards (Grid-mode
1778    // `Interval` decls are diverted to the cyclic vecs and never counted).
1779    // Consumed by `AttachmentMap::build` (#94), which sizes its lazy-learned
1780    // deadline-id bucket from this exact count.
1781    let mut deadline_count = 0usize;
1782    for (task_idx, task) in tasks.iter().enumerate() {
1783        for decl in &task.decls {
1784            if dispatch_mode == crate::DispatchMode::Grid {
1785                if let TriggerDecl::Interval(d) = decl {
1786                    // Grid mode owns cyclic timing via the master timer + GridTimer;
1787                    // these decls are NOT attached as individual WaitSet triggers.
1788                    cyclic_task_indices.push(task_idx);
1789                    cyclic_periods.push(u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
1790                    continue;
1791                }
1792            }
1793            // Count `Deadline` only on the path where it is actually attached
1794            // (after the Grid-`Interval` `continue`), so the tally matches the
1795            // guards pushed below one-for-one.
1796            if matches!(decl, TriggerDecl::Deadline { .. }) {
1797                deadline_count += 1;
1798            }
1799            let guard = attach_trigger_decl(waitset, listener_storage, decl)?;
1800            guards.push(guard);
1801            attachment_to_task.push(task_idx);
1802        }
1803    }
1804    Ok(deadline_count)
1805}
1806
1807/// Attaches a single [`TriggerDecl`] to `waitset`, returning the resulting
1808/// guard.
1809///
1810/// Listener-backed declarations (`Subscriber`, `Deadline`, `RawListener`)
1811/// clone the listener `Arc` into `listener_storage` to extend its lifetime to
1812/// the surrounding `dispatch_loop` scope; `Interval` attaches a bare timer.
1813///
1814/// # Safety
1815///
1816/// The returned guard borrows the listener via a raw-pointer cast that erases
1817/// its lifetime. Soundness relies on the caller keeping `listener_storage` (and
1818/// `waitset`) alive for at least as long as the guard, and dropping the guards
1819/// before `listener_storage` — exactly the discipline `dispatch_loop` follows.
1820#[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
1821fn attach_trigger_decl<'w>(
1822    waitset: &'w WaitSet<ipc::Service>,
1823    listener_storage: &mut Vec<Arc<crate::trigger::RawListener>>,
1824    decl: &TriggerDecl,
1825) -> Result<WaitSetGuard<'w, 'static, ipc::Service>, ExecutorError> {
1826    // Clone the listener Arc and obtain a lifetime-erased reference. SAFETY:
1827    // both `listener_storage` and `waitset` are stack-local in `dispatch_loop`
1828    // and dropped together at its end; guards are dropped before
1829    // `listener_storage`. The reference is fabricated as `'static` so the
1830    // 'attachment lifetime matches `WaitSet::attach_interval` (which yields a
1831    // `'static` attachment on iceoryx2 0.9), letting all three arms below unify
1832    // under `WaitSetGuard`'s invariance. `'static` is the maximal fabricated
1833    // lifetime; runtime soundness still rests solely on the drop-order discipline
1834    // documented above, not on the borrow.
1835    let mut listener_ref = |listener: &Arc<crate::trigger::RawListener>| {
1836        listener_storage.push(Arc::clone(listener));
1837        let l_ref = listener_storage.last().unwrap().as_ref();
1838        let l_ref: &'static crate::trigger::RawListener = unsafe { &*(l_ref as *const _) };
1839        l_ref
1840    };
1841
1842    let guard = match decl {
1843        TriggerDecl::Subscriber { listener } | TriggerDecl::RawListener(listener) => {
1844            waitset.attach_notification(listener_ref(listener))
1845        }
1846        TriggerDecl::Interval(d) => waitset.attach_interval(*d),
1847        TriggerDecl::Deadline { listener, deadline } => {
1848            waitset.attach_deadline(listener_ref(listener), *deadline)
1849        }
1850    };
1851    guard.map_err(ExecutorError::iceoryx2)
1852}
1853
1854/// Per-iteration dispatch context handed to the `WaitSet` callback.
1855///
1856/// `dispatch_loop` rebuilds one of these every iteration and the `WaitSet`
1857/// callback is a thin adapter over [`DispatchPass::process_attachment`]. All
1858/// fields are short-lived borrows / raw pointers into the `Executor` that owns
1859/// the surrounding `dispatch_loop`; their soundness is documented at each use
1860/// site in `dispatch_loop` (same single-threaded, barrier-bounded discipline).
1861struct DispatchPass<'a, 'g, 'w> {
1862    /// `WaitSet` guards, indexed in parallel with `attachment_to_task`.
1863    guards: &'a [WaitSetGuard<'g, 'w, ipc::Service>],
1864    /// Maps guard index to task index in `tasks_ptr`.
1865    attachment_to_task: &'a [usize],
1866    /// Raw pointer to `Executor::tasks`.
1867    tasks_ptr: *mut Vec<TaskEntry>,
1868    /// Raw pointer to `Executor::cycle_stats` (index-aligned with `tasks`).
1869    cycle_stats_ptr: *mut Vec<TaskCycleStats>,
1870    /// Borrow of the executor's observer for the `on_cycle_stats` push.
1871    observer: &'a Arc<dyn Observer>,
1872    /// Raw pointer to `Executor::exec_fault` inner state.
1873    exec_fault_ptr: *const ExecutorFaultAtomic,
1874    /// Raw pointer to `Executor::start_time`.
1875    exec_start_ptr: *const OnceLock<Instant>,
1876    /// Borrow of the executor's telemetry clock, read for each cycle's `pre`.
1877    clock: &'a Arc<dyn MonotonicClock>,
1878    /// Raw pointer to the internal stop listener.
1879    stop_listener_ptr: *const IxListener<ipc::Service>,
1880    /// Borrow of the executor thread pool.
1881    pool: &'a Pool,
1882    /// Refcount-only handle to the per-iteration error slot.
1883    iter_err: &'a Arc<std::sync::Mutex<Option<ExecutorError>>>,
1884}
1885
1886impl DispatchPass<'_, '_, '_> {
1887    /// Dispatches a single task by index for one wakeup: takes the `&mut`
1888    /// borrow into the task table, applies the pre-dispatch fault gate, stashes
1889    /// this cycle's `pending_cycle` timestamp for the post-barrier telemetry
1890    /// fold, and submits the task's work to the pool.
1891    ///
1892    /// Shared by the `WaitSet` callback (`process_attachment`) and — per
1893    /// `REQ_0268` / `ADR_0100` — the forthcoming post-wait absolute-grid
1894    /// dispatch pass, so the per-task barrier/telemetry contract is identical
1895    /// across both call paths.
1896    /// Grid-mode cyclic dispatch: stashes the dispatcher's skipped-slot
1897    /// signal (`REQ_0840`) on the task, then runs the shared dispatch path —
1898    /// the post-barrier `record_cycle_for` `take`s it and folds
1899    /// `grid_slot += 1 + skipped` (`REQ_0106` / `ADR_0101`). Only this
1900    /// discrete count crosses the scheduler→telemetry boundary; timestamps
1901    /// stay on the telemetry clock (`REQ_0268`).
1902    #[allow(unsafe_code)]
1903    fn dispatch_cyclic(&mut self, task_idx: usize, skipped: u64, late_by: u64) {
1904        // SAFETY: same single-writer WaitSet-thread discipline as
1905        // dispatch_task; the pointer is valid for the duration of this call.
1906        let task = unsafe { &mut (&mut *self.tasks_ptr)[task_idx] };
1907        // Post-validate_decls one-interval-per-task (#93), this fires at most
1908        // once per task per grid pass — so these pending_skipped/pending_late
1909        // writes cannot be clobbered by a same-phase re-entry, and the
1910        // dispatch_task `pending_cycle` guard is the structural backstop if that
1911        // invariant ever changes (e.g. the follow-up batched-barrier slice).
1912        task.pending_skipped = u32::try_from(skipped).unwrap_or(u32::MAX);
1913        task.pending_late = Some(late_by);
1914        self.dispatch_task(task_idx);
1915    }
1916
1917    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1918    #[allow(unsafe_code)]
1919    fn dispatch_task(&mut self, task_idx: usize) {
1920        // SAFETY: we are the only thread that may touch the task table
1921        // during the callback. wait_and_process_once is single-threaded
1922        // and dispatch_loop holds &mut self. The pointer is valid for the
1923        // duration of this call.
1924        let task = unsafe { &mut (&mut *self.tasks_ptr)[task_idx] };
1925
1926        // Per-phase dispatch dedup (REQ_0854, #93). `pending_cycle` is set at
1927        // dispatch and `take`n only by `barrier_and_record`. Still `Some` ⇒ this
1928        // task was already dispatched this wake-phase with no intervening
1929        // barrier; re-submitting the borrowed job (main item OR fault handler)
1930        // would alias one `*mut dyn FnMut` across two pool workers. Skip — the
1931        // first run's listener `take()` loop drains all pending input. This is
1932        // the structural backstop that makes the grid path (one barrier after
1933        // the whole due-loop) and the future batched-barrier slice sound by
1934        // construction.
1935        if task.pending_cycle.is_some() {
1936            return;
1937        }
1938
1939        // Pre-dispatch fault check (REQ_0070, REQ_0071, REQ_0072). When it
1940        // routes to a (possible) handler, normal dispatch is skipped.
1941        if self.handle_fault_routing(task) {
1942            // REQ_0107: a faulted/fault-routed scan STILL advances
1943            // cycle_index and emits on_cycle_stats, or the executor's count
1944            // desyncs from the connector's join key (FEAT_0038). took/jitter
1945            // are None (poison-safe); the index always moves. Allocation-free:
1946            // a CyclePending { Instant, bool } written onto the TaskEntry,
1947            // no heap.
1948            //
1949            // Set unconditionally (REQ_0854, #93): `pending_cycle` doubles as
1950            // the per-phase dedup token for ALL task kinds, so it must cover the
1951            // borrowed fault-handler submit too, not just cyclic tasks. Setting
1952            // it for an event task is telemetry-neutral — `record_cycle_for`
1953            // opens with `let Some(period) = task.scan_period else { return }`,
1954            // before any state mutation, so an event task's token produces zero
1955            // telemetry side effects (and REQ_0107's cyclic-faulted-scan cycle
1956            // advance is unaffected).
1957            task.pending_cycle = Some(CyclePending {
1958                pre: self.clock.now_nanos(),
1959                faulted: true,
1960            });
1961            return;
1962        }
1963
1964        // Stash the pre-dispatch instant so the post-barrier record pass
1965        // can fold this cycle's telemetry. Allocation-free: the timestamp
1966        // lives on the TaskEntry, not in a per-wakeup Vec. `take`n in the
1967        // post-barrier loop below — guarantees exactly-once even if two
1968        // guards map to the same task. `faulted: false`: a task that faulted
1969        // last wakeup and recovered this one records the normal path (the
1970        // whole CyclePending is overwritten, so the flag can't be stale).
1971        task.pending_cycle = Some(CyclePending {
1972            pre: self.clock.now_nanos(),
1973            faulted: false,
1974        });
1975
1976        self.submit_task_job(task);
1977    }
1978
1979    /// Handles a single `WaitSet` wakeup: drains stop notifications, then
1980    /// resolves the fired attachment id via `map` (an `O(log n)` lookup that
1981    /// falls back to `linear_scan` once per never-before-seen id, caching the
1982    /// result) and dispatches the single matching task. Always returns
1983    /// [`CallbackProgression::Continue`]; termination is decided by the
1984    /// `stop_flag` check in `dispatch_loop` after the callback returns.
1985    ///
1986    /// Mark-and-submit only (#95): this resolves the fired id and submits the
1987    /// matching task's borrowed job, but it does NOT barrier. The single
1988    /// `pool.barrier()` + telemetry fold per wake is deferred to
1989    /// [`DispatchPass::barrier_and_record`], run once in the guarded grid pass
1990    /// ([`run_grid_cyclic_pass_guarded`]); that fold covers BOTH the event
1991    /// tasks marked here and the grid cyclic tasks. The #93 per-phase dedup
1992    /// guard in [`DispatchPass::dispatch_task`] (`pending_cycle.is_some()` ⇒
1993    /// return) makes a task whose multiple listeners fire in the same wake
1994    /// dispatch exactly once — its one item run drains all ready listeners —
1995    /// rather than once per fired listener.
1996    ///
1997    /// Behaviour is identical to the prior linear guard sweep: at most one guard
1998    /// matches a given fired id (uniqueness invariant), so resolving-then-
1999    /// dispatching the one match is equivalent to looping and dispatching every
2000    /// match. Wake-only attachments (stop listener, master timer) resolve to
2001    /// [`crate::attachment_map::IGNORE`] and dispatch nothing.
2002    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2003    #[allow(unsafe_code)]
2004    fn process_attachment(
2005        &mut self,
2006        attachment_id: &WaitSetAttachmentId<ipc::Service>,
2007        map: &mut crate::attachment_map::AttachmentMap,
2008    ) -> CallbackProgression {
2009        // Drain stop notifications first (no dispatch — the stop_flag check
2010        // after the callback returns handles termination).
2011        // SAFETY: stop_listener_ptr is valid for the duration of the call;
2012        // the Arc in self.stop_listener keeps it alive.
2013        let stop_l = unsafe { &*self.stop_listener_ptr };
2014        while let Ok(Some(_)) = stop_l.try_wait_one() {}
2015
2016        // Resolve the fired id to its task index. `map` caches per-id; the
2017        // `slow` fallback is `linear_scan` over the same guard/task slices the
2018        // old loop walked. Capture the two slice fields locally so the closure
2019        // borrows just them (a shared borrow) rather than `&self` — `map` is a
2020        // separate object held `&mut`, so the borrows do not alias.
2021        let guards = self.guards;
2022        let attachment_to_task = self.attachment_to_task;
2023        let task_idx = map.resolve(attachment_id, |id| {
2024            linear_scan(guards, attachment_to_task, id)
2025        });
2026        if task_idx != crate::attachment_map::IGNORE {
2027            self.dispatch_task(task_idx);
2028        }
2029
2030        // No barrier here (#95): the lone per-wake `barrier_and_record` runs in
2031        // the guarded grid pass, folding both these event marks and the grid
2032        // cyclic marks together.
2033        CallbackProgression::Continue
2034    }
2035
2036    /// Barrier all submitted pool jobs for this dispatch phase, then fold each
2037    /// task's stashed `pending_cycle` into recorded cycle telemetry. Shared by
2038    /// the `WaitSet` callback (event/fd tasks) and the post-wait grid pass
2039    /// (cyclic tasks, `REQ_0268`). Keyed on `pending_cycle` so it records
2040    /// exactly the tasks dispatched this phase, exactly once.
2041    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2042    #[allow(unsafe_code)]
2043    fn barrier_and_record(&mut self) {
2044        // Wait for all submitted jobs to finish before leaving the callback
2045        // scope (validates item_ptr safety contract). The barrier also makes
2046        // every worker's `last_took_ns` Release-store visible to the record
2047        // pass below.
2048        self.pool.barrier();
2049
2050        // Post-barrier telemetry fold. The source of truth for "this task was
2051        // dispatched this wakeup and owes a record" is `pending_cycle`,
2052        // set in the dispatch loop above — not the guard fired-status. Keying
2053        // solely on the stash (rather than re-querying `has_event_from`)
2054        // removes any dependency on the fired-status query being stable across
2055        // a second scan, so a dispatched cycle can never be silently
2056        // under-recorded (which would lag `cycle_index` — the desync FEAT_0038
2057        // must avoid). `take` clears the stash, guaranteeing exactly-once.
2058        // Allocation-free: iterate task indices in place.
2059        // SAFETY: same single-writer WaitSet-thread discipline as the dispatch
2060        // loop above; barrier-bounded, no in-flight pool job aliases `tasks`.
2061        let task_count = unsafe { (*self.tasks_ptr).len() };
2062        for task_idx in 0..task_count {
2063            // SAFETY: single-writer WaitSet thread; borrow released before
2064            // the record_cycle_for call (which re-derefs tasks_ptr).
2065            let pending = unsafe { (&mut *self.tasks_ptr)[task_idx].pending_cycle.take() };
2066            if let Some(CyclePending { pre, faulted }) = pending {
2067                self.record_cycle_for(task_idx, faulted, pre);
2068            }
2069        }
2070    }
2071
2072    /// Fold one scan cycle's telemetry and push it to the observer. Called
2073    /// once per fired CYCLIC attachment per wakeup. `faulted = true` (Task 10)
2074    /// means the scan was skipped/errored: `took`/`jitter`/`lateness` are
2075    /// unmeasured. Event-driven tasks (no `scan_period`) are skipped entirely
2076    /// (`REQ_0106`).
2077    #[allow(unsafe_code)]
2078    fn record_cycle_for(&mut self, task_idx: usize, faulted: bool, pre_ns: u64) {
2079        // SAFETY: single-writer WaitSet thread; same discipline as tasks_ptr.
2080        let task = unsafe { &mut (&mut *self.tasks_ptr)[task_idx] };
2081        let Some(period) = task.scan_period else {
2082            return; // event-driven: no cycle telemetry
2083        };
2084        let period_ns = u64::try_from(period.as_nanos()).unwrap_or(u64::MAX);
2085
2086        // Release/Acquire pairing with the worker store (M2): `swap` acquires
2087        // the worker's Release-store and resets the sentinel atomically.
2088        let took_raw = task.last_took_ns.swap(u64::MAX, Ordering::AcqRel);
2089        let took = if faulted || took_raw == u64::MAX {
2090            None
2091        } else {
2092            Some(took_raw)
2093        };
2094
2095        // actual_period + jitter vs the previous dispatch (REQ_0101). Always
2096        // advance `last_dispatch` (even on a faulted attempt) so the next
2097        // cycle's period is measured from this wakeup. `actual_period` is
2098        // `None` on the very first cycle (no previous timestamp); jitter is
2099        // additionally suppressed on a faulted scan (poison-safe: REQ_0107).
2100        let actual_period = task
2101            .last_dispatch
2102            .replace(pre_ns)
2103            .map(|prev| pre_ns.saturating_sub(prev));
2104        let jitter = if faulted {
2105            None
2106        } else {
2107            actual_period.map(|ap| ap.abs_diff(period_ns))
2108        };
2109
2110        // Per-task lateness grid (REQ_0106 / ADR_0101): the task's first
2111        // record anchors the grid at slot 0 (a faulted first scan anchors
2112        // too, its dispatch instant is real); every later record advances by
2113        // exactly one slot plus the dispatcher's skipped-slot signal
2114        // (REQ_0840). Never reconstructed from the measured period, which
2115        // over-counts on coalesced catch-up wakes and fabricates negative
2116        // lateness (issue #46). The anchor is the first dispatch's NOMINAL
2117        // slot — `pre` back-dated by the dispatcher's `late_by` signal — so
2118        // a late process start is reported as real first-cycle lateness
2119        // instead of becoming a permanent negative floor on later on-grid
2120        // cycles. `late_by` is a scheduling-clock difference applied to a
2121        // telemetry-clock instant: domain-safe, both are monotonic ns.
2122        // Without a dispatcher signal (Legacy mode, event-driven tasks) the
2123        // anchor stays the first observed `pre`.
2124        let skipped = core::mem::take(&mut task.pending_skipped);
2125        let late_by = task.pending_late.take();
2126        let first = task.grid_epoch.is_none();
2127        let grid_epoch = *task
2128            .grid_epoch
2129            .get_or_insert_with(|| pre_ns.saturating_sub(late_by.unwrap_or(0)));
2130        if !first {
2131            task.grid_slot = task
2132                .grid_slot
2133                .saturating_add(1)
2134                .saturating_add(u64::from(skipped));
2135        }
2136        let grid_slot = task.grid_slot;
2137
2138        // SAFETY: cycle_stats is index-aligned with tasks; single-writer.
2139        let stats = unsafe { &mut (&mut *self.cycle_stats_ptr)[task_idx] };
2140
2141        // Deadline lateness (REQ_0106): signed offset of the actual start
2142        // (`pre_ns`) from its nominal grid point `grid_epoch + grid_slot *
2143        // period`. Positive => started late; negative => early. Captures
2144        // steady drift (jitter is blind to a constant offset; lateness is
2145        // not). A dispatcher skip re-anchors via `skipped` above; absent a
2146        // signal (e.g. `Legacy` mode, which never signals), a whole missed
2147        // period honestly remains visible as a persistent offset rather than
2148        // being absorbed.
2149        let lateness = if period_ns > 0 && !faulted {
2150            let elapsed_ns = i64::try_from(pre_ns.saturating_sub(grid_epoch)).unwrap_or(i64::MAX);
2151            let expected_ns =
2152                i64::try_from(u128::from(grid_slot) * u128::from(period_ns)).unwrap_or(i64::MAX);
2153            Some(elapsed_ns.saturating_sub(expected_ns))
2154        } else {
2155            None
2156        };
2157
2158        let cycle_index = stats.record_cycle(took, jitter, lateness);
2159
2160        let obs = CycleObservation {
2161            cycle_index,
2162            task_id: task.id.clone(),
2163            task_index: u32::try_from(task_idx).unwrap_or(u32::MAX),
2164            faulted,
2165            period_ns,
2166            pre_ns,
2167            actual_period_ns: actual_period,
2168            jitter_ns: jitter,
2169            lateness_ns: lateness,
2170            skipped_slots: skipped,
2171            took_ns: took,
2172        };
2173        self.observer.on_cycle_stats(&obs);
2174    }
2175
2176    /// Applies the pre-dispatch fault gate for `Single`/`Chain` tasks.
2177    ///
2178    /// Returns `true` when the task is routed to its fault handler (or
2179    /// silently skipped because no handler is registered) and normal dispatch
2180    /// must therefore be skipped. Returns `false` when normal dispatch should
2181    /// proceed. `Graph` tasks always return `false` — they use their own
2182    /// per-vertex scheduling and are out of scope for `FEAT_0018`.
2183    #[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
2184    fn handle_fault_routing(&self, task: &mut TaskEntry) -> bool {
2185        if !matches!(task.kind, TaskKind::Single(_) | TaskKind::Chain(_)) {
2186            return false;
2187        }
2188
2189        // SAFETY: exec_fault_ptr derefs into the Executor that owns the
2190        // surrounding dispatch_loop — alive for this call's lifetime.
2191        let exec_faulted = matches!(
2192            unsafe { &*self.exec_fault_ptr }.load(0, 0),
2193            ExecutorFaultState::Faulted { .. }
2194        );
2195        let task_budget_ms = task.budget.map_or(0_u32, duration_to_ms_sat);
2196        let task_state = task.fault.load(task_budget_ms);
2197
2198        // Lazy cascade: if executor is `Faulted` and task is still `Running`,
2199        // silently transition the task to `Faulted{ExecutorFaulted}`. No
2200        // `on_task_fault` — the Observer already heard about the executor-wide
2201        // fault via `on_executor_fault` (cascade-noise invariant, FEAT_0018
2202        // §4.6).
2203        let task_faulted = if exec_faulted && matches!(task_state, FaultState::Running) {
2204            // SAFETY: exec_start_ptr derefs into the same Executor owning the
2205            // dispatch_loop. The OnceLock is wait-free.
2206            let exec_start = *unsafe { &*self.exec_start_ptr }.get_or_init(std::time::Instant::now);
2207            let since_ms = instant_to_since_ms(std::time::Instant::now(), exec_start);
2208            let _ = task.fault.swap(
2209                FaultState::Faulted {
2210                    reason: FaultReason::ExecutorFaulted,
2211                    since_ms,
2212                },
2213                task_budget_ms,
2214            );
2215            true
2216        } else {
2217            matches!(task_state, FaultState::Faulted { .. })
2218        };
2219
2220        if !(exec_faulted || task_faulted) {
2221            return false;
2222        }
2223
2224        // If a handler is registered, dispatch it. Otherwise, skip dispatch
2225        // entirely this wakeup.
2226        if let Some(handler_box) = task.handler_job.as_deref_mut() {
2227            let job_ptr: *mut (dyn FnMut() + Send) = handler_box as *mut (dyn FnMut() + Send);
2228            // SAFETY: same as the main-job dispatch below — handler_job is
2229            // owned by the `TaskEntry`; the single per-wake
2230            // `barrier_and_record` in `run_grid_cyclic_pass_guarded` awaits
2231            // its completion before the next iteration (and before
2232            // `Executor` drop), so the borrowed job is never reused while
2233            // a worker still holds it.
2234            unsafe {
2235                self.pool
2236                    .submit_borrowed(crate::pool::BorrowedJob::new(job_ptr));
2237            }
2238        }
2239        true
2240    }
2241
2242    /// Dispatches `task`'s normal (non-fault) work for one wakeup.
2243    ///
2244    /// `Single`/`Chain` tasks submit their pre-built job to the pool;
2245    /// `Graph` tasks drive one pass and capture the first item error into the
2246    /// per-iteration error slot.
2247    #[deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2248    #[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
2249    fn submit_task_job(&self, task: &mut TaskEntry) {
2250        match &mut task.kind {
2251            TaskKind::Single(_) | TaskKind::Chain(_) => {
2252                // The dispatch closure was pre-allocated at task-add time and
2253                // stashed on `task.job`. Submit it via `submit_borrowed` — no
2254                // per-iteration Box allocation. Required by REQ_0060.
2255                #[allow(clippy::expect_used)]
2256                // fail-fast: Single/Chain task.job is always Some — set at add time in build_single_job/build_chain_job and never cleared
2257                let job_box = task
2258                    .job
2259                    .as_deref_mut()
2260                    .expect("Single/Chain tasks carry a pre-built job");
2261                let job_ptr: *mut (dyn FnMut() + Send) = job_box as *mut (dyn FnMut() + Send);
2262                // SAFETY: the closure lives in `task.job`, owned by
2263                // `self.tasks[task_idx]`; `tasks_ptr` is sound for the
2264                // duration of this callback. The single per-wake
2265                // `barrier_and_record` in `run_grid_cyclic_pass_guarded`
2266                // finishes the closure invocation before the next
2267                // iteration (and before `Executor` drop). The `WaitSet`
2268                // thread does not touch the closure between this submit and
2269                // that barrier, so there is no aliased reuse of the
2270                // borrowed job.
2271                unsafe {
2272                    self.pool
2273                        .submit_borrowed(crate::pool::BorrowedJob::new(job_ptr));
2274                }
2275            }
2276            TaskKind::Graph(graph) => {
2277                // Outer driver runs on the WaitSet thread; vertices run on the
2278                // pool. The graph holds its own pre-built per-vertex closures
2279                // and SPSC ready ring (REQ_0060), so dispatch is
2280                // allocation-free in steady state.
2281                let outcome = graph.run_once_borrowed(self.pool);
2282                if let Some(source) = outcome.error {
2283                    #[allow(clippy::unwrap_used)]
2284                    // fail-fast: poison unreachable — the lock is held only over an infallible Option insert/take, and any holder panic aborts the process before another thread observes it (ADR_0065)
2285                    let mut g = self.iter_err.lock().unwrap();
2286                    if g.is_none() {
2287                        *g = Some(ExecutorError::Item {
2288                            task_id: task.id.clone(),
2289                            source,
2290                        });
2291                    }
2292                }
2293                let _ = outcome.stopped_chain; // chain-abort semantics: no extra bookkeeping at task level
2294            }
2295        }
2296    }
2297}
2298
2299/// Wraps a `*mut dyn ExecutableItem` so it can cross thread boundaries inside
2300/// `Pool::submit`. The send is safe because:
2301///   1. The executor guarantees at most one invocation of a given item at a
2302///      time (via `pool.barrier()` before the pointer is reused).
2303///   2. `ExecutableItem: Send`, so moving the pointee across threads is sound
2304///      when no aliasing exists.
2305#[allow(unsafe_code)]
2306struct SendItemPtr {
2307    ptr: *mut dyn ExecutableItem,
2308}
2309
2310impl SendItemPtr {
2311    fn new(ptr: *mut dyn ExecutableItem) -> Self {
2312        Self { ptr }
2313    }
2314
2315    /// Returns the raw pointer. Takes `&self` so the wrapper can be invoked
2316    /// repeatedly from an `FnMut` dispatch closure (`REQ_0060` requires the
2317    /// dispatch closure to be reusable across iterations without allocation).
2318    fn get(&self) -> *mut dyn ExecutableItem {
2319        self.ptr
2320    }
2321}
2322
2323// SAFETY: see doc comment above. `Sync` is required so the FnMut dispatch
2324// closure can borrow `&SendItemPtr` per invocation without making the
2325// closure itself `!Send`.
2326#[allow(unsafe_code)]
2327unsafe impl Send for SendItemPtr {}
2328#[allow(unsafe_code)]
2329unsafe impl Sync for SendItemPtr {}
2330
2331/// Wraps a `*mut Vec<Box<dyn ExecutableItem>>` so a chain dispatch
2332/// closure can iterate the chain's items in place without first
2333/// collecting them into a freshly-allocated `Vec`. The send is safe
2334/// for the same reason as [`SendItemPtr`] (see above): the executor
2335/// holds `&mut self` for the duration of `dispatch_loop`, and the
2336/// `pool.barrier()` at the end of each callback ensures the closure
2337/// has finished using this pointer before the Vec could be touched
2338/// from the `WaitSet` thread again. The Vec is never resized after
2339/// dispatch begins. Required for `REQ_0060` — chain dispatch must not
2340/// allocate per iteration.
2341#[allow(unsafe_code)]
2342struct SendChainPtr {
2343    ptr: *mut Vec<Box<dyn ExecutableItem>>,
2344}
2345
2346impl SendChainPtr {
2347    fn new(ptr: *mut Vec<Box<dyn ExecutableItem>>) -> Self {
2348        Self { ptr }
2349    }
2350
2351    fn get(&self) -> *mut Vec<Box<dyn ExecutableItem>> {
2352        self.ptr
2353    }
2354}
2355
2356// SAFETY: see doc comment above. `Sync` lets the FnMut dispatch closure
2357// borrow `&SendChainPtr` per invocation while staying `Send`.
2358#[allow(unsafe_code)]
2359unsafe impl Send for SendChainPtr {}
2360#[allow(unsafe_code)]
2361unsafe impl Sync for SendChainPtr {}
2362
2363/// Captured state needed by a dispatch closure to perform post-execute
2364/// fault detection. All fields are `Arc`-shared with the owning
2365/// `Executor` and `TaskEntry` so the closure can read/write them
2366/// wait-free from any pool worker thread. `REQ_0070`, `REQ_0071`,
2367/// `REQ_0102`.
2368struct FaultDispatchCtx {
2369    /// Per-task budget. `None` for chain / graph tasks (no per-task
2370    /// check) — the executor-wide iteration budget still applies.
2371    task_budget: Option<Duration>,
2372    /// Per-task fault state (shared with `TaskEntry::fault`).
2373    task_fault: Arc<FaultAtomic>,
2374    /// Per-task monotonic overrun counter (shared with
2375    /// `TaskEntry::overrun_count`). Increments on EVERY budget breach.
2376    overrun_count: Arc<AtomicU64>,
2377    /// Executor-wide iteration budget. `None` means no executor-wide
2378    /// check.
2379    iteration_budget: Option<Duration>,
2380    /// Executor-wide fault state (shared with `Executor::exec_fault`).
2381    exec_fault: Arc<ExecutorFaultAtomic>,
2382    /// Executor-wide offending-task index storage (shared with
2383    /// `Executor::exec_fault_task_idx`).
2384    exec_fault_task_idx: Arc<AtomicU32>,
2385    /// Executor-wide breached-budget storage (shared with
2386    /// `Executor::exec_fault_budget_ms`).
2387    exec_fault_budget_ms: Arc<AtomicU32>,
2388    /// Index of this task in the executor's task table.
2389    task_idx_u32: u32,
2390    /// Executor start time (shared with `Executor::start_time`).
2391    exec_start: Arc<OnceLock<Instant>>,
2392    /// Observer for `on_task_fault` / `on_executor_fault` notifications.
2393    observer: Arc<dyn Observer>,
2394}
2395
2396/// Validate a task's collected trigger declarations before it joins the task
2397/// table (`REQ_0268`). Applied at every add path — single, chain head, and
2398/// fault-handler main — at the point the `TriggerDecl`s are first available,
2399/// regardless of [`DispatchMode`] (the rejected shapes are ill-defined in any
2400/// mode; Legacy is temporary).
2401///
2402/// Rejects two shapes:
2403///
2404/// 1. **Cyclic AND event-driven** — a task carrying both an `Interval` decl and
2405///    any listener-backed decl (`Subscriber` / `Deadline` / `RawListener`). Per
2406///    `REQ_0106` a task is cyclic XOR event-driven: cyclic tasks have a
2407///    period/lateness, event-driven tasks do not. Allowing both would dispatch
2408///    and record the task twice in one wake (phase-a event + phase-b grid),
2409///    desyncing the `FEAT_0038` `cycle_index` join key (`REQ_0107`).
2410/// 2. **Zero-period interval** — an `Interval(Duration::ZERO)` busy-spins the
2411///    grid (`GridTimer::next_timeout` returns `0` every wake and `take_due`
2412///    re-fires without advancing). A zero scan period is nonsensical.
2413fn validate_decls(id: &TaskId, decls: &[crate::trigger::TriggerDecl]) -> Result<(), ExecutorError> {
2414    use crate::trigger::TriggerDecl;
2415
2416    let has_interval = decls.iter().any(|d| matches!(d, TriggerDecl::Interval(_)));
2417    let has_listener = decls.iter().any(|d| {
2418        matches!(
2419            d,
2420            TriggerDecl::Subscriber { .. }
2421                | TriggerDecl::Deadline { .. }
2422                | TriggerDecl::RawListener(_)
2423        )
2424    });
2425
2426    if has_interval && has_listener {
2427        return Err(ExecutorError::DeclareTriggers(format!(
2428            "task `{id}` declares both an interval (cyclic) and a listener \
2429             (event-driven) trigger; a task may be cyclic (interval) or \
2430             event-driven (listener) but not both — split it into two tasks"
2431        )));
2432    }
2433
2434    // Exactly one scan period per cyclic task (REQ_0002, #93). Two interval
2435    // decls share the grid epoch in Grid mode (REQ_0268), come due in the same
2436    // pass, and would submit one borrowed `*mut dyn FnMut` to two pool workers
2437    // before the single barrier — a data race on the Linux-default path. Reject
2438    // at the validate_decls chokepoint, which covers both `add` and
2439    // `add_chain_with_id_boxed`. Multi-listener stays legal.
2440    let interval_count = decls
2441        .iter()
2442        .filter(|d| matches!(d, TriggerDecl::Interval(_)))
2443        .count();
2444    if interval_count > 1 {
2445        return Err(ExecutorError::DeclareTriggers(format!(
2446            "task `{id}` declares {interval_count} interval triggers; a cyclic \
2447             task must declare exactly one scan period — split it into separate \
2448             tasks"
2449        )));
2450    }
2451
2452    if decls
2453        .iter()
2454        .any(|d| matches!(d, TriggerDecl::Interval(dur) if dur.is_zero()))
2455    {
2456        return Err(ExecutorError::DeclareTriggers(format!(
2457            "task `{id}` declares a zero-duration interval; a cyclic scan \
2458             period must be strictly positive"
2459        )));
2460    }
2461
2462    Ok(())
2463}
2464
2465/// Extract the declared scan period (first `Interval` trigger) from a task's
2466/// trigger declarations, or `None` for event-driven tasks.
2467fn scan_period_from_decls(decls: &[crate::trigger::TriggerDecl]) -> Option<Duration> {
2468    decls.iter().find_map(|d| match d {
2469        crate::trigger::TriggerDecl::Interval(dur) => Some(*dur),
2470        _ => None,
2471    })
2472}
2473
2474/// Build the per-iteration dispatch closure for a `TaskKind::Single`.
2475///
2476/// The returned closure is stored on `TaskEntry::job` and invoked once
2477/// per dispatch via `Pool::submit_borrowed`, which (unlike `submit`)
2478/// performs no allocation. The closure captures Arc clones of the
2479/// executor's shared state — those clones are refcount-only at build
2480/// time and are reused on every dispatch. Required for `REQ_0060`.
2481#[allow(clippy::too_many_arguments)]
2482fn build_single_job(
2483    id: TaskId,
2484    stop: Stoppable,
2485    obs: Arc<dyn Observer>,
2486    mon: Arc<dyn ExecutionMonitor>,
2487    err_slot: Arc<std::sync::Mutex<Option<ExecutorError>>>,
2488    app_id: Option<u32>,
2489    app_inst: Option<u32>,
2490    item_ptr: SendItemPtr,
2491    fault_ctx: FaultDispatchCtx,
2492    last_took_ns: Arc<AtomicU64>,
2493    clock: Arc<dyn MonotonicClock>,
2494) -> Box<dyn FnMut() + Send + 'static> {
2495    Box::new(move || {
2496        let mut ctx = crate::context::Context::new(&id, &stop, obs.as_ref());
2497        if let Some(aid) = app_id {
2498            obs.on_app_start(id.clone(), aid, app_inst);
2499        }
2500        let raw = item_ptr.get();
2501        let started = std::time::Instant::now();
2502        // Telemetry `took` is measured on the injected clock (REQ_0105) so a
2503        // MockClock can make it exact; the real `started`/`took` below stay on
2504        // the system clock for the monitor and fault-budget paths.
2505        let tele_t0 = clock.now_nanos();
2506        mon.pre_execute(id.clone(), started);
2507        // SAFETY: barrier() pairs with this invocation; the WaitSet
2508        // thread does not touch the item between `submit_borrowed` and
2509        // the matching `barrier()`. See SendItemPtr safety doc.
2510        #[allow(unsafe_code)]
2511        let res = run_item_catch_unwind(unsafe { &mut *raw }, &mut ctx);
2512        let took = started.elapsed();
2513        // Release pairs with the WaitSet-thread Acquire (swap) in
2514        // `record_cycle_for` (M2). `pool.barrier()` also fences, but the
2515        // explicit pairing documents intent and is robust on weak-memory archs.
2516        last_took_ns.store(clock.now_nanos().saturating_sub(tele_t0), Ordering::Release);
2517        mon.post_execute(id.clone(), started, took, res.is_ok());
2518        if let Err(ref e) = res {
2519            obs.on_app_error(id.clone(), e.as_ref());
2520        }
2521        if app_id.is_some() {
2522            obs.on_app_stop(id.clone());
2523        }
2524        post_execute_detect_fault(&id, started, took, &fault_ctx);
2525        record_first_err(&err_slot, &id, res);
2526    })
2527}
2528
2529/// Build the per-iteration dispatch closure for a fault-handler item.
2530///
2531/// Mirrors [`build_single_job`] in every detail (same monitor /
2532/// observer / first-error capture wiring) but owns the
2533/// `Box<dyn ExecutableItem>` directly inside the closure instead of
2534/// dereferencing a raw [`SendItemPtr`]. The handler has no parallel
2535/// owner inside [`TaskEntry`] — the handler closure stored in
2536/// `handler_job` is the sole owner — so the simpler owning form is
2537/// both sound and avoids the aliasing dance the main item needs.
2538/// (Unlike [`build_single_job`], this closure does NOT update
2539/// `last_took_ns` — the handler runs in place of the main item, so the
2540/// main item's `last_took_ns` keeps its sentinel `u64::MAX` = "no
2541/// sample this cycle".)
2542/// `REQ_0072`.
2543#[allow(clippy::too_many_arguments)]
2544fn build_handler_job(
2545    id: TaskId,
2546    stop: Stoppable,
2547    obs: Arc<dyn Observer>,
2548    mon: Arc<dyn ExecutionMonitor>,
2549    err_slot: Arc<std::sync::Mutex<Option<ExecutorError>>>,
2550    app_id: Option<u32>,
2551    app_inst: Option<u32>,
2552    mut handler: Box<dyn ExecutableItem>,
2553    fault_ctx: FaultDispatchCtx,
2554) -> Box<dyn FnMut() + Send + 'static> {
2555    Box::new(move || {
2556        let mut ctx = crate::context::Context::new(&id, &stop, obs.as_ref());
2557        if let Some(aid) = app_id {
2558            obs.on_app_start(id.clone(), aid, app_inst);
2559        }
2560        let started = std::time::Instant::now();
2561        mon.pre_execute(id.clone(), started);
2562        let res = run_item_catch_unwind(handler.as_mut(), &mut ctx);
2563        let took = started.elapsed();
2564        mon.post_execute(id.clone(), started, took, res.is_ok());
2565        if let Err(ref e) = res {
2566            obs.on_app_error(id.clone(), e.as_ref());
2567        }
2568        if app_id.is_some() {
2569            obs.on_app_stop(id.clone());
2570        }
2571        // Per §4.6 invariant 5 of FEAT_0018: a handler that ALSO breaches
2572        // budget keeps the task in `Faulted` (state already `Faulted`),
2573        // `overrun_count` increments, NO new `on_task_fault` fires —
2574        // the `matches!(prev, FaultState::Running)` gate inside
2575        // `post_execute_detect_fault` enforces that.
2576        post_execute_detect_fault(&id, started, took, &fault_ctx);
2577        record_first_err(&err_slot, &id, res);
2578    })
2579}
2580
2581/// Build the per-iteration dispatch closure for a `TaskKind::Chain`.
2582#[allow(clippy::too_many_arguments)]
2583fn build_chain_job(
2584    id: TaskId,
2585    stop: Stoppable,
2586    obs: Arc<dyn Observer>,
2587    mon: Arc<dyn ExecutionMonitor>,
2588    err_slot: Arc<std::sync::Mutex<Option<ExecutorError>>>,
2589    chain_ptr: SendChainPtr,
2590    fault_ctx: FaultDispatchCtx,
2591    last_took_ns: Arc<AtomicU64>,
2592    clock: Arc<dyn MonotonicClock>,
2593) -> Box<dyn FnMut() + Send + 'static> {
2594    Box::new(move || {
2595        let mut ctx = crate::context::Context::new(&id, &stop, obs.as_ref());
2596        // Overall chain scan timer — the chain's `took` is the elapsed
2597        // telemetry-clock time from the first item's pre-execute to the last
2598        // item's completion (or early break), mirroring the single-item `took`
2599        // notion (REQ_0105). Per-item monitor timing uses each item's own
2600        // real-clock `started` below.
2601        let chain_tele_t0 = clock.now_nanos();
2602        // SAFETY: barrier() pairs with this invocation; the chain Vec
2603        // and the items it owns are not touched by the WaitSet thread
2604        // until barrier() returns. See SendChainPtr safety doc.
2605        #[allow(unsafe_code)]
2606        let chain_items = unsafe { &mut *chain_ptr.get() };
2607        for item_box in chain_items.iter_mut() {
2608            let app_id = item_box.app_id();
2609            let app_inst = item_box.app_instance_id();
2610            if let Some(aid) = app_id {
2611                obs.on_app_start(id.clone(), aid, app_inst);
2612            }
2613            let raw = std::ptr::from_mut::<dyn ExecutableItem>(item_box.as_mut());
2614            let started = std::time::Instant::now();
2615            mon.pre_execute(id.clone(), started);
2616            #[allow(unsafe_code)]
2617            let res = run_item_catch_unwind(unsafe { &mut *raw }, &mut ctx);
2618            let took = started.elapsed();
2619            mon.post_execute(id.clone(), started, took, res.is_ok());
2620            if let Err(ref e) = res {
2621                obs.on_app_error(id.clone(), e.as_ref());
2622            }
2623            if app_id.is_some() {
2624                obs.on_app_stop(id.clone());
2625            }
2626            // Per-item post-execute fault detection. `task_budget` is
2627            // `None` for chains (see `add_chain_with_id_boxed`), so the
2628            // per-task check no-ops; the executor-wide iteration-budget
2629            // check still fires per item. `REQ_0071`.
2630            post_execute_detect_fault(&id, started, took, &fault_ctx);
2631            match res {
2632                Ok(crate::ItemFlow::Continue) => {}
2633                Ok(crate::ItemFlow::StopChain) => break,
2634                Err(_) => {
2635                    record_first_err(&err_slot, &id, res);
2636                    break;
2637                }
2638            }
2639        }
2640        // Release pairs with the WaitSet-thread Acquire (swap) in
2641        // `record_cycle_for` (M2). See the Single-job store for the rationale.
2642        last_took_ns.store(
2643            clock.now_nanos().saturating_sub(chain_tele_t0),
2644            Ordering::Release,
2645        );
2646    })
2647}
2648
2649#[derive(Debug)]
2650struct PanickedTask(String);
2651
2652impl core::fmt::Display for PanickedTask {
2653    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2654        write!(f, "task panicked: {}", self.0)
2655    }
2656}
2657
2658impl std::error::Error for PanickedTask {}
2659
2660/// Execute `item` inside `catch_unwind`, converting any panic into an `Err`.
2661fn run_item_catch_unwind(
2662    item: &mut dyn ExecutableItem,
2663    ctx: &mut crate::context::Context<'_>,
2664) -> crate::ExecuteResult {
2665    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| item.execute(ctx))).unwrap_or_else(
2666        |payload| {
2667            let msg =
2668                panic_payload_message(&*payload).unwrap_or_else(|| "panicked task".to_string());
2669            Err::<crate::ItemFlow, crate::ItemError>(Box::new(PanickedTask(msg)))
2670        },
2671    )
2672}
2673
2674/// Public-within-crate wrapper so `graph.rs` can call `run_item_catch_unwind`
2675/// without depending on its private name.
2676pub(crate) fn run_item_catch_unwind_external(
2677    item: &mut dyn ExecutableItem,
2678    ctx: &mut crate::context::Context<'_>,
2679) -> crate::ExecuteResult {
2680    run_item_catch_unwind(item, ctx)
2681}
2682
2683/// Record the first error into `slot`. Subsequent errors are silently dropped.
2684fn record_first_err(
2685    slot: &Arc<std::sync::Mutex<Option<ExecutorError>>>,
2686    id: &TaskId,
2687    res: crate::ExecuteResult,
2688) {
2689    if let Err(source) = res {
2690        let mut g = slot.lock().unwrap();
2691        if g.is_none() {
2692            *g = Some(ExecutorError::Item {
2693                task_id: id.clone(),
2694                source,
2695            });
2696        }
2697    }
2698}
2699
2700/// Post-execute fault detection — runs on a pool worker AFTER
2701/// `mon.post_execute` so the full `took` is available. Implements:
2702///
2703///   * `REQ_0070` / `REQ_0102` — per-task budget overrun: increments
2704///     `overrun_count` on every breach, transitions
2705///     `Running -> Faulted{BudgetExceeded}` exactly once (subsequent
2706///     breaches keep the state `Faulted` and do NOT re-fire the
2707///     observer).
2708///   * `REQ_0071` — executor-wide iteration overrun: transitions
2709///     `Running -> Faulted{IterationBudgetExceeded}` exactly once;
2710///     cascade to per-task state is LAZY (see the pre-dispatch block
2711///     in `dispatch_loop`), so the per-task `on_task_fault` does NOT
2712///     fire during cascade — only `on_executor_fault` does.
2713fn post_execute_detect_fault(
2714    id: &TaskId,
2715    started: Instant,
2716    took: Duration,
2717    fault_ctx: &FaultDispatchCtx,
2718) {
2719    // REQ_0070 / REQ_0102 — per-task budget overrun.
2720    if let Some(budget) = fault_ctx.task_budget {
2721        if took > budget {
2722            fault_ctx.overrun_count.fetch_add(1, Ordering::Relaxed);
2723            let took_ms = duration_to_ms_sat(took);
2724            let budget_ms = duration_to_ms_sat(budget);
2725            let exec_start = *fault_ctx.exec_start.get_or_init(|| started);
2726            let since_ms = instant_to_since_ms(started, exec_start);
2727            let new_state = FaultState::Faulted {
2728                reason: FaultReason::BudgetExceeded { took_ms, budget_ms },
2729                since_ms,
2730            };
2731            let prev = fault_ctx.task_fault.swap(new_state, budget_ms);
2732            if matches!(prev, FaultState::Running) {
2733                fault_ctx.observer.on_task_fault(
2734                    id.clone(),
2735                    FaultReason::BudgetExceeded { took_ms, budget_ms },
2736                );
2737            }
2738        }
2739    }
2740
2741    // REQ_0071 — executor-wide iteration overrun.
2742    if let Some(iter_budget) = fault_ctx.iteration_budget {
2743        if took > iter_budget {
2744            let took_ms = duration_to_ms_sat(took);
2745            let budget_ms = duration_to_ms_sat(iter_budget);
2746            let exec_start = *fault_ctx.exec_start.get_or_init(|| started);
2747            let since_ms = instant_to_since_ms(started, exec_start);
2748            fault_ctx
2749                .exec_fault_task_idx
2750                .store(fault_ctx.task_idx_u32, Ordering::Release);
2751            fault_ctx
2752                .exec_fault_budget_ms
2753                .store(budget_ms, Ordering::Release);
2754            let new_state = ExecutorFaultState::Faulted {
2755                reason: ExecutorFaultReason::IterationBudgetExceeded {
2756                    task_idx: fault_ctx.task_idx_u32,
2757                    took_ms,
2758                    budget_ms,
2759                },
2760                since_ms,
2761            };
2762            let prev = fault_ctx
2763                .exec_fault
2764                .swap(new_state, fault_ctx.task_idx_u32, budget_ms);
2765            if matches!(prev, ExecutorFaultState::Running) {
2766                fault_ctx.observer.on_executor_fault(
2767                    ExecutorFaultReason::IterationBudgetExceeded {
2768                        task_idx: fault_ctx.task_idx_u32,
2769                        took_ms,
2770                        budget_ms,
2771                    },
2772                );
2773                // NO eager cascade here. Cascade is lazy: the
2774                // pre-dispatch block in `dispatch_loop` transitions
2775                // each `Running` task to `Faulted{ExecutorFaulted}` on
2776                // the next wakeup — silently, so per-task observers
2777                // do not fire (see §4.6 invariant on cascade-noise).
2778            }
2779        }
2780    }
2781}
2782
2783// ── ExecutorGraphBuilder ──────────────────────────────────────────────────────
2784
2785/// Borrowed wrapper that finalises a [`GraphBuilder`](crate::graph::GraphBuilder)
2786/// into a registered task.
2787pub struct ExecutorGraphBuilder<'e> {
2788    executor: &'e mut Executor,
2789    builder: crate::graph::GraphBuilder,
2790    custom_id: Option<TaskId>,
2791}
2792
2793impl ExecutorGraphBuilder<'_> {
2794    /// Add a vertex to the graph; returns its handle.
2795    pub fn vertex<I: ExecutableItem>(&mut self, item: I) -> crate::graph::Vertex {
2796        self.builder.vertex(item)
2797    }
2798
2799    /// Add a directed edge from one vertex to another.
2800    pub fn edge(&mut self, from: crate::graph::Vertex, to: crate::graph::Vertex) -> &mut Self {
2801        self.builder.edge(from, to);
2802        self
2803    }
2804
2805    /// Designate the root vertex (its triggers gate the graph).
2806    pub const fn root(&mut self, v: crate::graph::Vertex) -> &mut Self {
2807        self.builder.root(v);
2808        self
2809    }
2810
2811    /// Override the auto-generated id with a custom one.
2812    pub fn id(&mut self, id: impl Into<TaskId>) -> &mut Self {
2813        self.custom_id = Some(id.into());
2814        self
2815    }
2816
2817    /// Validate and register the graph. Returns the task id.
2818    ///
2819    /// The root vertex's [`ExecutableItem::task_id`] override takes precedence
2820    /// over any id set via [`ExecutorGraphBuilder::id`], which itself takes
2821    /// precedence over the auto-generated id.
2822    pub fn build(self) -> Result<TaskId, ExecutorError> {
2823        let g = self.builder.finish()?;
2824        // Root vertex's task_id() override wins over the custom id, which wins
2825        // over the auto-generated fallback.
2826        let auto_id = || {
2827            TaskId::new(format!(
2828                "graph-{}",
2829                self.executor.next_id.fetch_add(1, Ordering::SeqCst)
2830            ))
2831        };
2832        let id = g
2833            .root_task_id()
2834            .map(TaskId::new)
2835            .or(self.custom_id)
2836            .unwrap_or_else(auto_id);
2837        let decls = g.decls.clone();
2838        // The graph root's decls become a grid-registered TaskEntry, so the same
2839        // cyclic-XOR-event-driven / non-zero-period validation that guards the
2840        // single-item, fault-handler, and chain add paths must guard this one too
2841        // (REQ_0268). Non-root vertex triggers never reach a TaskEntry — they are
2842        // discarded in `GraphBuilder::collect_root_decls` — so validating the root
2843        // decls is sufficient.
2844        validate_decls(&id, &decls)?;
2845        let scan_period = scan_period_from_decls(&decls);
2846
2847        // Box the graph for address stability — per-vertex dispatch
2848        // closures capture `*const Graph` and must not see it move.
2849        let mut graph_box: Box<crate::graph::Graph> = Box::new(g);
2850        // Pre-build the per-vertex closures now that we know the
2851        // task_id and have access to the executor's shared state.
2852        graph_box.prepare_dispatch(
2853            id.clone(),
2854            self.executor.stoppable.clone(),
2855            Arc::clone(&self.executor.observer),
2856            Arc::clone(&self.executor.monitor),
2857            Arc::clone(&self.executor.iter_err),
2858        );
2859
2860        self.executor.tasks.push(TaskEntry {
2861            id: id.clone(),
2862            kind: TaskKind::Graph(graph_box),
2863            decls,
2864            // Graph tasks dispatch their vertices via `vertex_jobs`
2865            // stored inside the `Graph`; the per-task `job` slot
2866            // is unused for graphs.
2867            job: None,
2868            // TODO(post-Task-10): graph budgets carried separately; for now None.
2869            budget: None,
2870            fault: Arc::new(FaultAtomic::new()),
2871            overrun_count: Arc::new(AtomicU64::new(0)),
2872            handler_job: None,
2873            scan_period,
2874            // Graphs dispatch vertices via their own path and do not ferry a
2875            // per-task `took`; sentinel = "no sample". Wired for struct
2876            // completeness; nothing reads it yet (Task 6).
2877            last_took_ns: Arc::new(AtomicU64::new(u64::MAX)),
2878            last_dispatch: None,
2879            grid_slot: 0,
2880            grid_epoch: None,
2881            pending_skipped: 0,
2882            pending_late: None,
2883            pending_cycle: None,
2884        });
2885        self.executor
2886            .cycle_stats
2887            .push(TaskCycleStats::new(self.executor.stats_window));
2888        Ok(id)
2889    }
2890}
2891
2892// ── Test seam ─────────────────────────────────────────────────────────────────
2893
2894#[cfg(test)]
2895impl Executor {
2896    /// White-box seam (`#93`, `REQ_0854`): reproduce the batched-barrier / grid
2897    /// wake — two dispatches of one task with a SINGLE barrier between them —
2898    /// over a guard-less [`DispatchPass`] built from our own fields.
2899    /// `dispatch_task` / `barrier_and_record` never read `guards` /
2900    /// `attachment_to_task`, so empty slices suffice; `stop_listener_ptr` points
2901    /// at the real Arc. Pins the per-phase dedup contract: the second dispatch
2902    /// must be skipped, so the borrowed job (main item or fault handler) is
2903    /// submitted at most once — never aliased across two pool workers.
2904    #[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
2905    fn dispatch_twice_one_barrier(&mut self, task_idx: usize) {
2906        // Raw pointers taken first so the &mut borrows are released before the
2907        // shared borrows below — same discipline as `dispatch_loop`.
2908        let tasks_ptr = &mut self.tasks as *mut Vec<TaskEntry>;
2909        let cycle_stats_ptr = &mut self.cycle_stats as *mut Vec<TaskCycleStats>;
2910        let exec_fault_ptr = &*self.exec_fault as *const ExecutorFaultAtomic;
2911        let exec_start_ptr = &*self.start_time as *const OnceLock<Instant>;
2912        let stop_listener_ptr = self.stop_listener.as_ref() as *const IxListener<ipc::Service>;
2913        let observer = &self.observer;
2914        let pool = &self.pool;
2915        let clock = &self.clock;
2916        let iter_err = Arc::clone(&self.iter_err);
2917        let mut pass = DispatchPass {
2918            guards: &[],
2919            attachment_to_task: &[],
2920            tasks_ptr,
2921            cycle_stats_ptr,
2922            observer,
2923            exec_fault_ptr,
2924            exec_start_ptr,
2925            clock,
2926            stop_listener_ptr,
2927            pool,
2928            iter_err: &iter_err,
2929        };
2930        pass.dispatch_task(task_idx);
2931        pass.dispatch_task(task_idx);
2932        pass.barrier_and_record();
2933    }
2934}
2935
2936// ── Unit tests ────────────────────────────────────────────────────────────────
2937
2938#[cfg(test)]
2939mod tests {
2940    use super::*;
2941    use crate::{ItemFlow, item};
2942    use iceoryx2::prelude::ZeroCopySend;
2943
2944    /// Minimal zero-copy payload for tests that need a real subscriber to
2945    /// produce a listener-backed trigger decl.
2946    #[derive(Debug, Default, Clone, Copy, ZeroCopySend)]
2947    #[repr(C)]
2948    struct Msg(u32);
2949
2950    #[test]
2951    fn add_returns_unique_ids() {
2952        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
2953        let a = exec.add(item(|_| Ok(ItemFlow::Continue))).unwrap();
2954        let b = exec.add(item(|_| Ok(ItemFlow::Continue))).unwrap();
2955        assert_ne!(a, b);
2956    }
2957
2958    #[test]
2959    fn dispatch_guard_runs_event_task_once_per_phase() {
2960        use std::sync::Arc;
2961        use std::sync::atomic::{AtomicU64, Ordering};
2962        // REQ_0854 / #93: two dispatches of one task with a single barrier
2963        // between them (the future batched-barrier / grid pattern) must submit
2964        // the borrowed main job at most ONCE — re-submitting the same
2965        // `*mut dyn FnMut` would alias it across two pool workers. An event task
2966        // (no scan period) exercises the normal `submit_task_job` path.
2967        let runs = Arc::new(AtomicU64::new(0));
2968        let r = Arc::clone(&runs);
2969        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
2970        exec.add(item(move |_| {
2971            r.fetch_add(1, Ordering::Relaxed);
2972            Ok(ItemFlow::Continue)
2973        }))
2974        .expect("add");
2975        exec.dispatch_twice_one_barrier(0);
2976        assert_eq!(
2977            runs.load(Ordering::Relaxed),
2978            1,
2979            "borrowed main job must be submitted exactly once per barrier phase"
2980        );
2981    }
2982
2983    #[test]
2984    fn dispatch_guard_runs_fault_handler_once_per_phase() {
2985        use crate::fault::{FaultReason, FaultState};
2986        use std::sync::Arc;
2987        use std::sync::atomic::{AtomicU64, Ordering};
2988        // REQ_0854 / #93: the dedup token must cover the BORROWED FAULT HANDLER
2989        // submit, not just the main job. An event task (no scan period) routed
2990        // to its handler twice in one barrier phase must run the handler once.
2991        // Before the uniform-token fix, the fault branch only set `pending_cycle`
2992        // for cyclic tasks, so an event task's handler aliased across workers.
2993        let runs = Arc::new(AtomicU64::new(0));
2994        let r = Arc::clone(&runs);
2995        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
2996        exec.add_with_fault_handler(
2997            item(|_| Ok(ItemFlow::Continue)),
2998            item(move |_| {
2999                r.fetch_add(1, Ordering::Relaxed);
3000                Ok(ItemFlow::Continue)
3001            }),
3002        )
3003        .expect("add_with_fault_handler");
3004        // Drive the task to Faulted so dispatch routes to the handler.
3005        exec.tasks[0].fault.swap(
3006            FaultState::Faulted {
3007                reason: FaultReason::BudgetExceeded {
3008                    took_ms: 6,
3009                    budget_ms: 2,
3010                },
3011                since_ms: 0,
3012            },
3013            0,
3014        );
3015        exec.dispatch_twice_one_barrier(0);
3016        assert_eq!(
3017            runs.load(Ordering::Relaxed),
3018            1,
3019            "borrowed fault handler must be submitted exactly once per barrier phase"
3020        );
3021    }
3022
3023    #[test]
3024    fn grid_mode_dispatches_cyclic_task_each_cycle() {
3025        use std::sync::Arc;
3026        use std::sync::atomic::{AtomicU64, Ordering};
3027        let hits = Arc::new(AtomicU64::new(0));
3028        let h = Arc::clone(&hits);
3029        let mut exec = Executor::builder()
3030            .worker_threads(0)
3031            .dispatch_mode(crate::DispatchMode::Grid)
3032            .build()
3033            .expect("build");
3034        exec.add(crate::item::item_with_triggers(
3035            move |d| {
3036                d.interval(std::time::Duration::from_millis(1));
3037                Ok(())
3038            },
3039            move |_ctx| {
3040                h.fetch_add(1, Ordering::Relaxed);
3041                Ok(ItemFlow::Continue)
3042            },
3043        ))
3044        .expect("add");
3045        exec.run_n(10).expect("run");
3046        assert!(
3047            hits.load(Ordering::Relaxed) >= 8,
3048            "grid mode under-dispatched: {}",
3049            hits.load(Ordering::Relaxed)
3050        );
3051    }
3052
3053    #[test]
3054    fn legacy_mode_dispatches_cyclic_task_each_cycle() {
3055        use std::sync::Arc;
3056        use std::sync::atomic::{AtomicU64, Ordering};
3057        let hits = Arc::new(AtomicU64::new(0));
3058        let h = Arc::clone(&hits);
3059        let mut exec = Executor::builder()
3060            .worker_threads(0)
3061            .dispatch_mode(crate::DispatchMode::Legacy)
3062            .build()
3063            .expect("build");
3064        exec.add(crate::item::item_with_triggers(
3065            move |d| {
3066                d.interval(std::time::Duration::from_millis(1));
3067                Ok(())
3068            },
3069            move |_ctx| {
3070                h.fetch_add(1, Ordering::Relaxed);
3071                Ok(ItemFlow::Continue)
3072            },
3073        ))
3074        .expect("add");
3075        exec.run_n(10).expect("run");
3076        assert!(
3077            hits.load(Ordering::Relaxed) >= 8,
3078            "legacy mode under-dispatched: {}",
3079            hits.load(Ordering::Relaxed)
3080        );
3081    }
3082
3083    #[test]
3084    fn stranded_pending_cycle_token_does_not_swallow_first_dispatch() {
3085        use std::sync::Arc;
3086        use std::sync::atomic::{AtomicU32, Ordering};
3087        // Regression for #102: the `dispatch_loop` entry sweep clears the three
3088        // per-wake transients (REQ_0854, #93) so a prior `run_*` that bailed
3089        // mid-wake cannot strand them. This test presets ALL THREE on the task
3090        // and asserts the two that are first-cycle observable under `run_n(1)`:
3091        //
3092        //   * `pending_cycle = Some(..)` — doubles as the per-phase dispatch
3093        //     dedup token. `dispatch_task`'s `pending_cycle.is_some()` guard
3094        //     would silently swallow the task's first dispatch if the sweep
3095        //     didn't clear it (the dispatch-count assertion guards this clear).
3096        //   * `pending_late = Some(L)` — `record_cycle_for` back-dates the
3097        //     first-cycle `grid_epoch` via `pre_ns.saturating_sub(L)` (the
3098        //     `get_or_insert_with` anchor). `pre_ns` is ns since the clock's
3099        //     build epoch, so this early in a run it's small (~1 ms); with
3100        //     `L = 5_000_000 > pre_ns` the subtraction saturates to 0, so the
3101        //     first recorded cycle's `lateness_ns` reads a nonzero value
3102        //     (`≈ pre_ns`, the dispatch instant, in this saturating regime; it
3103        //     would equal `L` only once `pre_ns >= L`) instead of the clean
3104        //     `0` — a silently corrupted lateness baseline for the whole run
3105        //     (the lateness assertion guards this clear).
3106        //   * `pending_skipped = <nonzero>` — `mem::take`n by the same record
3107        //     pass, but the first record has `first == true`, so it does NOT
3108        //     advance `grid_slot` and has no first-cycle observable effect.
3109        //     Its clear is defensive (it would only bite a later cycle), so
3110        //     there is intentionally no assertion on it here; a `run_n(2)`
3111        //     test for it would be flaky on real timers. Presetting it is for
3112        //     completeness, to prove the sweep clears all three together.
3113        //
3114        // The sweep makes the token lifetime one `dispatch_loop` invocation by
3115        // construction even though `Executor` is `&mut self` and `self.tasks`
3116        // persists across `run_*` calls.
3117        struct LatenessRecorder {
3118            lateness: std::sync::Mutex<Vec<Option<i64>>>,
3119        }
3120        impl Observer for LatenessRecorder {
3121            fn on_cycle_stats(&self, obs: &CycleObservation) {
3122                self.lateness.lock().expect("lock").push(obs.lateness_ns);
3123            }
3124        }
3125
3126        let hits = Arc::new(AtomicU32::new(0));
3127        let h = Arc::clone(&hits);
3128        let recorder = Arc::new(LatenessRecorder {
3129            lateness: std::sync::Mutex::new(Vec::new()),
3130        });
3131        // Legacy mode is mandatory for scripted/deterministic cyclic tests: the
3132        // platform default races on macOS and skips the first Grid cycle on
3133        // ubuntu.
3134        let mut exec = Executor::builder()
3135            .worker_threads(0)
3136            .dispatch_mode(crate::DispatchMode::Legacy)
3137            .observer(Arc::clone(&recorder) as Arc<dyn Observer>)
3138            .build()
3139            .expect("build");
3140        exec.add(crate::item::item_with_triggers(
3141            move |d| {
3142                d.interval(std::time::Duration::from_millis(1));
3143                Ok(())
3144            },
3145            move |_ctx| {
3146                h.fetch_add(1, Ordering::Relaxed);
3147                Ok(ItemFlow::Continue)
3148            },
3149        ))
3150        .expect("add");
3151
3152        // Simulate the stranded-token precondition: a prior `dispatch_loop`
3153        // bailed mid-wake after marking the task but before folding it, leaving
3154        // all three per-wake transients `Some`/nonzero.
3155        exec.tasks[0].pending_cycle = Some(CyclePending {
3156            pre: 0,
3157            faulted: false,
3158        });
3159        exec.tasks[0].pending_skipped = 7;
3160        exec.tasks[0].pending_late = Some(5_000_000);
3161
3162        exec.run_n(1).expect("run");
3163
3164        // Guards the `pending_cycle = None` clear: the handler fires exactly
3165        // once. Without the clear, the dedup guard swallows the first dispatch.
3166        assert_eq!(
3167            hits.load(Ordering::Relaxed),
3168            1,
3169            "stale pending_cycle token swallowed the first dispatch (#102)"
3170        );
3171
3172        // Guards the `pending_late = None` clear. First cycle ⇒ `grid_slot = 0`,
3173        // `expected = 0`, `elapsed = pre - grid_epoch`. With the clear,
3174        // `late_by = None` ⇒ `grid_epoch = pre` ⇒ lateness `Some(0)`. Without it,
3175        // `late_by = Some(5_000_000)` ⇒ `grid_epoch = pre.saturating_sub(L)`,
3176        // which saturates to 0 here (`L > pre`, `pre` being ns since the clock
3177        // epoch), so lateness reads a nonzero `Some(≈ pre)` — the dispatch
3178        // instant, not `L` (it would read `Some(L)` only once `pre >= L`). The
3179        // assertion turns only on clean-being-exactly-`Some(0)` vs corrupted-
3180        // being-nonzero, not on the corrupted magnitude.
3181        let first_lateness = *recorder
3182            .lateness
3183            .lock()
3184            .expect("lock")
3185            .first()
3186            .expect("at least one cycle observation must have been recorded");
3187        assert_eq!(
3188            first_lateness,
3189            Some(0),
3190            "stale pending_late token corrupted the first-cycle lateness baseline (#102)"
3191        );
3192    }
3193
3194    #[test]
3195    #[allow(unsafe_code, clippy::ref_as_ptr, clippy::borrow_as_ptr)]
3196    fn cyclic_fold_observer_panic_routes_to_fatal_boundary() {
3197        use crate::fatal::{FatalContext, FatalDispatch, FatalSite};
3198        use std::sync::{Arc, Mutex};
3199        // #103 / REQ_0123: the post-wait grid pass folds cyclic telemetry
3200        // through `barrier_and_record` -> `record_cycle_for` ->
3201        // `Observer::on_cycle_stats`, a USER callback that can panic. Latent on
3202        // `main`, that fold ran OUTSIDE the `guard_or_fatal` framework-fault
3203        // boundary, so an observer panic unwound raw out of `dispatch_loop`
3204        // instead of routing to `fatal.fire(...)` -> abort. This drives a
3205        // panicking cycle-stats observer through `run_grid_cyclic_pass_guarded`
3206        // and asserts the boundary catches it (`None` + one ExecutorRunLoop
3207        // fire). Deterministic white-box, modeled on `dispatch_twice_one_barrier`
3208        // — a `run_n` Grid integration test is unreliable (macOS interleave
3209        // race + ubuntu first-cycle-skip flake), so the fold might not fire.
3210
3211        // (2) Observer whose on_cycle_stats panics.
3212        struct BoomObserver;
3213        impl Observer for BoomObserver {
3214            fn on_cycle_stats(&self, _obs: &CycleObservation) {
3215                panic!("observer boom");
3216            }
3217        }
3218
3219        // (1) One cyclic (interval) task so it has a `scan_period` and
3220        // `record_cycle_for` does NOT early-return.
3221        let period = std::time::Duration::from_millis(1);
3222        let mut exec = Executor::builder()
3223            .worker_threads(0)
3224            .observer(Arc::new(BoomObserver) as Arc<dyn Observer>)
3225            .build()
3226            .expect("build");
3227        exec.add(crate::item::item_with_triggers(
3228            move |d| {
3229                d.interval(period);
3230                Ok(())
3231            },
3232            |_ctx| Ok(ItemFlow::Continue),
3233        ))
3234        .expect("add");
3235        assert_eq!(
3236            exec.tasks[0].scan_period,
3237            Some(period),
3238            "cyclic task must carry a scan_period or record_cycle_for early-returns"
3239        );
3240
3241        // (3) Swap in a recording terminal so `guard_or_fatal` returns `None`
3242        // instead of aborting; capture every fired site.
3243        let fired: Arc<Mutex<Vec<FatalSite>>> = Arc::new(Mutex::new(Vec::new()));
3244        let fired2 = Arc::clone(&fired);
3245        exec.fatal_dispatch = Arc::new(FatalDispatch::with_terminal(
3246            exec.fatal_dispatch.handler().clone(),
3247            move |ctx: &FatalContext| {
3248                fired2.lock().expect("lock").push(ctx.site);
3249            },
3250        ));
3251
3252        // (4) Hand-build a guard-less DispatchPass over our own fields (raw
3253        // pointers taken first so the &mut borrows release before the shared
3254        // borrows below — same discipline as `dispatch_twice_one_barrier`).
3255        let tasks_ptr = &mut exec.tasks as *mut Vec<TaskEntry>;
3256        let cycle_stats_ptr = &mut exec.cycle_stats as *mut Vec<TaskCycleStats>;
3257        let exec_fault_ptr = &*exec.exec_fault as *const ExecutorFaultAtomic;
3258        let exec_start_ptr = &*exec.start_time as *const OnceLock<Instant>;
3259        let stop_listener_ptr = exec.stop_listener.as_ref() as *const IxListener<ipc::Service>;
3260        let observer = &exec.observer;
3261        let pool = &exec.pool;
3262        let clock = &exec.clock;
3263        let iter_err = Arc::clone(&exec.iter_err);
3264        let stop_flag = exec.stoppable.clone();
3265        let fatal = Arc::clone(&exec.fatal_dispatch);
3266        let pass = DispatchPass {
3267            guards: &[],
3268            attachment_to_task: &[],
3269            tasks_ptr,
3270            cycle_stats_ptr,
3271            observer,
3272            exec_fault_ptr,
3273            exec_start_ptr,
3274            clock,
3275            stop_listener_ptr,
3276            pool,
3277            iter_err: &iter_err,
3278        };
3279
3280        // (5) Build a GridTimer and force the single cyclic task DUE. `take_due`
3281        // mutates the timer (it advances `next` past the served slot), so probe
3282        // due-ness on a throwaway timer and hand the pass a fresh, un-advanced
3283        // one — `run_grid_cyclic_pass` calls `take_due` itself, and a re-call on
3284        // an already-advanced timer at the same `now` would find nothing.
3285        let period_ns = u64::try_from(period.as_nanos()).expect("period fits u64");
3286        let base_now = 0_u64;
3287        let now = base_now + period_ns;
3288        let cyclic_task_indices = vec![0_usize];
3289        let mut due_probe: Vec<(usize, u64, u64)> = Vec::new();
3290        crate::grid::GridTimer::new(base_now, vec![period_ns]).take_due(now, &mut due_probe);
3291        assert!(
3292            !due_probe.is_empty(),
3293            "grid setup wrong: cyclic task is not due, the fold would never fire"
3294        );
3295        let mut grid = crate::grid::GridTimer::new(base_now, vec![period_ns]);
3296        let mut due_cyclic: Vec<(usize, u64, u64)> = Vec::new();
3297
3298        // (6) Drive the guarded grid pass: the cyclic task dispatches, the
3299        // internal barrier runs, `record_cycle_for` calls the panicking
3300        // observer -> panic -> caught by `guard_or_fatal` -> fatal terminal
3301        // records -> returns `None`.
3302        let outcome = run_grid_cyclic_pass_guarded(
3303            &fatal,
3304            pass,
3305            true,
3306            crate::DispatchMode::Grid,
3307            &stop_flag,
3308            Ok(WaitSetRunResult::AllEventsHandled),
3309            &mut grid,
3310            now,
3311            &cyclic_task_indices,
3312            &mut due_cyclic,
3313        );
3314
3315        // (7) The boundary caught the panic: `None` and exactly one fire at the
3316        // run-loop site.
3317        assert!(
3318            outcome.is_none(),
3319            "observer panic in the cyclic fold must be caught by the framework boundary (None)"
3320        );
3321        let sites = fired.lock().expect("lock").clone();
3322        assert_eq!(
3323            sites,
3324            vec![FatalSite::ExecutorRunLoop],
3325            "expected exactly one ExecutorRunLoop fatal fire, got {sites:?}"
3326        );
3327    }
3328
3329    // --- REQ_0268 trigger-combination validation (Fix 1 / Fix 3) ---
3330
3331    #[test]
3332    fn add_rejects_cyclic_plus_subscriber_combination() {
3333        use core::time::Duration;
3334        // A task declaring BOTH an Interval and a listener-backed trigger is
3335        // ill-defined (cyclic XOR event-driven, REQ_0106) and must be rejected
3336        // at add time. We use a real subscriber so the listener decl is genuine.
3337        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3338        let ch = exec.channel::<Msg>("taktora.test.req0268.combo").unwrap();
3339        let sub = ch.subscriber().unwrap();
3340        let err = exec
3341            .add(crate::item::item_with_triggers(
3342                move |d| {
3343                    d.interval(Duration::from_millis(1));
3344                    d.subscriber(&sub);
3345                    Ok(())
3346                },
3347                |_| Ok(crate::ItemFlow::Continue),
3348            ))
3349            .expect_err("interval + subscriber must be rejected");
3350        match err {
3351            ExecutorError::DeclareTriggers(msg) => {
3352                assert!(
3353                    msg.contains("cyclic") && msg.contains("event-driven"),
3354                    "message must explain cyclic vs event-driven: {msg}"
3355                );
3356                assert!(
3357                    msg.contains("split"),
3358                    "message must suggest splitting into two tasks: {msg}"
3359                );
3360            }
3361            other => panic!("expected DeclareTriggers, got {other:?}"),
3362        }
3363    }
3364
3365    #[test]
3366    fn add_rejects_cyclic_plus_listener_regardless_of_mode() {
3367        use core::time::Duration;
3368        // The combination is ill-defined irrespective of DispatchMode (Legacy
3369        // is temporary), so Legacy must reject it too.
3370        let mut exec = Executor::builder()
3371            .worker_threads(0)
3372            .dispatch_mode(crate::DispatchMode::Legacy)
3373            .build()
3374            .unwrap();
3375        let ch = exec
3376            .channel::<Msg>("taktora.test.req0268.combo.legacy")
3377            .unwrap();
3378        let sub = ch.subscriber().unwrap();
3379        let err = exec
3380            .add(crate::item::item_with_triggers(
3381                move |d| {
3382                    d.interval(Duration::from_millis(1));
3383                    d.subscriber(&sub);
3384                    Ok(())
3385                },
3386                |_| Ok(crate::ItemFlow::Continue),
3387            ))
3388            .expect_err("interval + subscriber must be rejected in Legacy too");
3389        assert!(matches!(err, ExecutorError::DeclareTriggers(_)));
3390    }
3391
3392    #[test]
3393    fn add_accepts_single_interval_and_multiple_listeners() {
3394        use core::time::Duration;
3395        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3396        // Single interval: accepted (the only legal cyclic shape — REQ_0002).
3397        exec.add(crate::item::item_with_triggers(
3398            |d| {
3399                d.interval(Duration::from_millis(1));
3400                Ok(())
3401            },
3402            |_| Ok(crate::ItemFlow::Continue),
3403        ))
3404        .expect("single interval accepted");
3405        // Multiple listeners (no interval): still accepted (multi-listener is
3406        // legal; only multi-interval is rejected — #93).
3407        let ch = exec
3408            .channel::<Msg>("taktora.test.req0268.multi.listener")
3409            .unwrap();
3410        let sub_a = ch.subscriber().unwrap();
3411        let sub_b = ch.subscriber().unwrap();
3412        exec.add(crate::item::item_with_triggers(
3413            move |d| {
3414                d.subscriber(&sub_a);
3415                d.subscriber(&sub_b);
3416                Ok(())
3417            },
3418            |_| Ok(crate::ItemFlow::Continue),
3419        ))
3420        .expect("multiple listeners accepted");
3421    }
3422
3423    #[test]
3424    fn add_rejects_multiple_intervals() {
3425        use core::time::Duration;
3426        // Two interval() decls on one task: in Grid mode (Linux default) both
3427        // share the grid epoch, come due in the same pass, and would submit one
3428        // borrowed FnMut to two pool workers before the single barrier — a data
3429        // race. A cyclic task must declare exactly one scan period (REQ_0002),
3430        // so reject at add() time. (#93)
3431        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3432        let err = exec
3433            .add(crate::item::item_with_triggers(
3434                |d| {
3435                    d.interval(Duration::from_millis(1));
3436                    d.interval(Duration::from_millis(2));
3437                    Ok(())
3438                },
3439                |_| Ok(crate::ItemFlow::Continue),
3440            ))
3441            .expect_err("multiple intervals must be rejected");
3442        match err {
3443            ExecutorError::DeclareTriggers(msg) => {
3444                assert!(
3445                    msg.contains("interval"),
3446                    "message must mention the interval triggers: {msg}"
3447                );
3448            }
3449            other => panic!("expected DeclareTriggers, got {other:?}"),
3450        }
3451    }
3452
3453    #[test]
3454    fn add_rejects_zero_period_interval() {
3455        use core::time::Duration;
3456        // A zero-period interval busy-spins the grid (next_timeout == 0 every
3457        // wake), so it must be rejected at add time.
3458        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3459        let err = exec
3460            .add(crate::item::item_with_triggers(
3461                |d| {
3462                    d.interval(Duration::ZERO);
3463                    Ok(())
3464                },
3465                |_| Ok(crate::ItemFlow::Continue),
3466            ))
3467            .expect_err("zero-period interval must be rejected");
3468        match err {
3469            ExecutorError::DeclareTriggers(msg) => {
3470                assert!(
3471                    msg.contains("zero"),
3472                    "message must mention the zero period: {msg}"
3473                );
3474            }
3475            other => panic!("expected DeclareTriggers, got {other:?}"),
3476        }
3477    }
3478
3479    #[test]
3480    fn add_chain_rejects_cyclic_plus_listener() {
3481        use core::time::Duration;
3482        // The chain path collects the head item's decls; the same validation
3483        // must apply there.
3484        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3485        let ch = exec
3486            .channel::<Msg>("taktora.test.req0268.chain.combo")
3487            .unwrap();
3488        let sub = ch.subscriber().unwrap();
3489        let err = exec
3490            .add_chain(vec![crate::item::item_with_triggers(
3491                move |d| {
3492                    d.interval(Duration::from_millis(1));
3493                    d.subscriber(&sub);
3494                    Ok(())
3495                },
3496                |_| Ok(crate::ItemFlow::Continue),
3497            )])
3498            .expect_err("chain head interval + subscriber must be rejected");
3499        assert!(matches!(err, ExecutorError::DeclareTriggers(_)));
3500    }
3501
3502    #[test]
3503    fn add_chain_rejects_zero_period_interval() {
3504        use core::time::Duration;
3505        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3506        let err = exec
3507            .add_chain(vec![crate::item::item_with_triggers(
3508                |d| {
3509                    d.interval(Duration::ZERO);
3510                    Ok(())
3511                },
3512                |_| Ok(crate::ItemFlow::Continue),
3513            )])
3514            .expect_err("chain head zero-period interval must be rejected");
3515        assert!(matches!(err, ExecutorError::DeclareTriggers(_)));
3516    }
3517
3518    #[test]
3519    fn add_chain_rejects_multiple_intervals() {
3520        use core::time::Duration;
3521        // The chain path collects the head item's decls through the same
3522        // validate_decls chokepoint, so the one-scan-period rule (REQ_0002, #93)
3523        // covers chains for free — pin it.
3524        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3525        let err = exec
3526            .add_chain(vec![crate::item::item_with_triggers(
3527                |d| {
3528                    d.interval(Duration::from_millis(1));
3529                    d.interval(Duration::from_millis(2));
3530                    Ok(())
3531                },
3532                |_| Ok(crate::ItemFlow::Continue),
3533            )])
3534            .expect_err("chain head with two intervals must be rejected");
3535        match err {
3536            ExecutorError::DeclareTriggers(msg) => assert!(
3537                msg.contains("interval"),
3538                "message must mention the interval triggers: {msg}"
3539            ),
3540            other => panic!("expected DeclareTriggers, got {other:?}"),
3541        }
3542    }
3543
3544    #[test]
3545    fn add_graph_rejects_cyclic_plus_listener() {
3546        use core::time::Duration;
3547        // The graph path collects the root vertex's decls into a grid-registered
3548        // TaskEntry; the same cyclic-XOR-event-driven validation must apply there
3549        // (REQ_0268). We use a real subscriber so the listener decl is genuine.
3550        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3551        let ch = exec
3552            .channel::<Msg>("taktora.test.req0268.graph.combo")
3553            .unwrap();
3554        let sub = ch.subscriber().unwrap();
3555        let mut g = exec.add_graph();
3556        let r = g.vertex(crate::item::item_with_triggers(
3557            move |d| {
3558                d.interval(Duration::from_millis(1));
3559                d.subscriber(&sub);
3560                Ok(())
3561            },
3562            |_| Ok(crate::ItemFlow::Continue),
3563        ));
3564        g.root(r);
3565        let err = g
3566            .build()
3567            .expect_err("graph root interval + subscriber must be rejected");
3568        assert!(matches!(err, ExecutorError::DeclareTriggers(_)));
3569    }
3570
3571    #[test]
3572    fn add_graph_rejects_zero_period_interval() {
3573        use core::time::Duration;
3574        // A zero-period interval on the graph root busy-spins the grid, so the
3575        // graph path must reject it just like the single-item/chain paths.
3576        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3577        let mut g = exec.add_graph();
3578        let r = g.vertex(crate::item::item_with_triggers(
3579            |d| {
3580                d.interval(Duration::ZERO);
3581                Ok(())
3582            },
3583            |_| Ok(crate::ItemFlow::Continue),
3584        ));
3585        g.root(r);
3586        let err = g
3587            .build()
3588            .expect_err("graph root zero-period interval must be rejected");
3589        assert!(matches!(err, ExecutorError::DeclareTriggers(_)));
3590    }
3591
3592    #[test]
3593    fn stopped_iteration_emits_no_cyclic_cycle_observation() {
3594        use core::time::Duration;
3595        use std::sync::atomic::AtomicU64;
3596
3597        // A CyclicClock that starts at 0 (epoch) then jumps far past the first
3598        // grid target, so the post-wait `take_due` finds the cyclic task due on
3599        // the very first (stopping) wake. Distinct from the telemetry clock
3600        // (scheduling role).
3601        struct JumpClock {
3602            calls: AtomicU64,
3603        }
3604        impl crate::CyclicClock for JumpClock {
3605            fn now_nanos(&self) -> u64 {
3606                // First read (grid epoch at loop entry) = 0; every later read
3607                // is well past the 1ms target.
3608                if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
3609                    0
3610                } else {
3611                    1_000_000_000
3612                }
3613            }
3614        }
3615
3616        // Observer that counts on_cycle_stats calls.
3617        struct Counter {
3618            cycles: AtomicU64,
3619        }
3620        impl Observer for Counter {
3621            fn on_cycle_stats(&self, _obs: &CycleObservation) {
3622                self.cycles.fetch_add(1, Ordering::SeqCst);
3623            }
3624        }
3625
3626        let counter = Arc::new(Counter {
3627            cycles: AtomicU64::new(0),
3628        });
3629        let mut exec = Executor::builder()
3630            .worker_threads(0)
3631            .dispatch_mode(crate::DispatchMode::Grid)
3632            .cyclic_clock(Arc::new(JumpClock {
3633                calls: AtomicU64::new(0),
3634            }))
3635            .observer(Arc::clone(&counter) as Arc<dyn Observer>)
3636            .build()
3637            .unwrap();
3638        exec.add(crate::item::item_with_triggers(
3639            |d| {
3640                d.interval(Duration::from_millis(1));
3641                Ok(())
3642            },
3643            |_| Ok(crate::ItemFlow::Continue),
3644        ))
3645        .unwrap();
3646
3647        // Stop BEFORE running: the WaitSet wakes immediately on the stop
3648        // listener; the grid target is already due (JumpClock). Without the
3649        // stop guard the post-wait cyclic pass would dispatch + record one
3650        // spurious cycle on this stopping iteration; with it, zero.
3651        exec.stoppable().stop();
3652        exec.run().expect("run returns cleanly after stop");
3653
3654        assert_eq!(
3655            counter.cycles.load(Ordering::SeqCst),
3656            0,
3657            "no cyclic cycle observation may be emitted on a stop wake"
3658        );
3659    }
3660
3661    #[test]
3662    fn custom_id_is_preserved() {
3663        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3664        let id = exec
3665            .add_with_id("my-task", item(|_| Ok(ItemFlow::Continue)))
3666            .unwrap();
3667        assert_eq!(id.as_str(), "my-task");
3668    }
3669
3670    #[test]
3671    fn add_persists_declared_budget() {
3672        use core::time::Duration;
3673        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3674        let task_id = exec
3675            .add(crate::item::item_with_triggers(
3676                |d| {
3677                    d.interval(Duration::from_millis(10));
3678                    d.budget(Duration::from_millis(5));
3679                    Ok(())
3680                },
3681                |_| Ok(crate::ItemFlow::Continue),
3682            ))
3683            .unwrap();
3684        let entry = exec
3685            .tasks
3686            .iter()
3687            .find(|t| t.id == task_id)
3688            .expect("task present");
3689        assert_eq!(entry.budget, Some(Duration::from_millis(5)));
3690    }
3691
3692    #[test]
3693    fn scan_period_cached_for_cyclic_only() {
3694        use core::time::Duration;
3695        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3696        let cyclic = exec
3697            .add(crate::item::item_with_triggers(
3698                |d| {
3699                    d.interval(Duration::from_millis(5));
3700                    Ok(())
3701                },
3702                |_| Ok(crate::ItemFlow::Continue),
3703            ))
3704            .unwrap();
3705        let event_driven = exec.add(item(|_| Ok(ItemFlow::Continue))).unwrap();
3706
3707        let cyclic_entry = exec
3708            .tasks
3709            .iter()
3710            .find(|t| t.id == cyclic)
3711            .expect("cyclic task present");
3712        assert_eq!(cyclic_entry.scan_period, Some(Duration::from_millis(5)));
3713        // Sentinel: no sample has been taken yet.
3714        assert_eq!(cyclic_entry.last_took_ns.load(Ordering::Relaxed), u64::MAX);
3715
3716        let event_entry = exec
3717            .tasks
3718            .iter()
3719            .find(|t| t.id == event_driven)
3720            .expect("event-driven task present");
3721        assert_eq!(event_entry.scan_period, None);
3722    }
3723
3724    #[test]
3725    fn cycle_stats_index_aligned_with_tasks() {
3726        use core::time::Duration;
3727        let mut exec = Executor::builder()
3728            .worker_threads(0)
3729            .stats_window(512)
3730            .build()
3731            .unwrap();
3732        // Builder option flows through to the executor.
3733        assert_eq!(exec.stats_window, 512);
3734        // No tasks yet → both Vecs empty and aligned.
3735        assert_eq!(exec.cycle_stats.len(), exec.tasks.len());
3736
3737        // Cyclic single-item add path.
3738        exec.add(crate::item::item_with_triggers(
3739            |d| {
3740                d.interval(Duration::from_millis(5));
3741                Ok(())
3742            },
3743            |_| Ok(crate::ItemFlow::Continue),
3744        ))
3745        .unwrap();
3746        // Event-driven single-item add path.
3747        exec.add(item(|_| Ok(ItemFlow::Continue))).unwrap();
3748
3749        assert_eq!(exec.tasks.len(), 2);
3750        assert_eq!(exec.cycle_stats.len(), exec.tasks.len());
3751    }
3752
3753    #[test]
3754    fn add_with_fault_handler_stores_handler_job() {
3755        use core::time::Duration;
3756        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3757        let task_id = exec
3758            .add_with_fault_handler(
3759                crate::item::item_with_triggers(
3760                    |d| {
3761                        d.interval(Duration::from_millis(10));
3762                        d.budget(Duration::from_millis(5));
3763                        Ok(())
3764                    },
3765                    |_| Ok(crate::ItemFlow::Continue),
3766                ),
3767                crate::item::item_with_triggers(|_d| Ok(()), |_| Ok(crate::ItemFlow::Continue)),
3768            )
3769            .unwrap();
3770        let entry = exec
3771            .tasks
3772            .iter()
3773            .find(|t| t.id == task_id)
3774            .expect("task present");
3775        assert!(
3776            entry.handler_job.is_some(),
3777            "handler_job should be Some after add_with_fault_handler"
3778        );
3779        // Main job should still be present.
3780        assert!(entry.job.is_some(), "main job should still be present");
3781    }
3782
3783    #[test]
3784    fn declare_triggers_called_at_add_time() {
3785        let called = Arc::new(AtomicBool::new(false));
3786        let called_d = Arc::clone(&called);
3787
3788        let it = crate::item::item_with_triggers(
3789            move |_d| {
3790                called_d.store(true, Ordering::SeqCst);
3791                Ok(())
3792            },
3793            |_| Ok(ItemFlow::Continue),
3794        );
3795
3796        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3797        exec.add(it).unwrap();
3798        assert!(called.load(Ordering::SeqCst));
3799    }
3800
3801    #[test]
3802    fn clear_task_fault_errors_on_running_task() {
3803        use core::time::Duration;
3804        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3805        let task_id = exec
3806            .add(crate::item::item_with_triggers(
3807                |d| {
3808                    d.interval(Duration::from_millis(10));
3809                    Ok(())
3810                },
3811                |_| Ok(crate::ItemFlow::Continue),
3812            ))
3813            .unwrap();
3814        // Task starts in Running state — clearing should error.
3815        let err = exec.clear_task_fault(task_id).expect_err("not faulted");
3816        assert!(matches!(err, ExecutorError::TaskNotFaulted(_)));
3817    }
3818
3819    #[test]
3820    fn clear_executor_fault_errors_on_running_executor() {
3821        let exec = Executor::builder().worker_threads(0).build().unwrap();
3822        let err = exec.clear_executor_fault().expect_err("not faulted");
3823        assert!(matches!(err, ExecutorError::ExecutorNotFaulted));
3824    }
3825
3826    #[test]
3827    fn overrun_count_returns_zero_for_new_task() {
3828        use core::time::Duration;
3829        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3830        let task_id = exec
3831            .add(crate::item::item_with_triggers(
3832                |d| {
3833                    d.interval(Duration::from_millis(10));
3834                    d.budget(Duration::from_millis(5));
3835                    Ok(())
3836                },
3837                |_| Ok(crate::ItemFlow::Continue),
3838            ))
3839            .unwrap();
3840        assert_eq!(exec.overrun_count(task_id).unwrap(), 0);
3841    }
3842
3843    #[test]
3844    fn overrun_count_errors_for_unknown_task() {
3845        let exec = Executor::builder().worker_threads(0).build().unwrap();
3846        let err = exec
3847            .overrun_count(crate::TaskId::new("nope"))
3848            .expect_err("unknown task");
3849        assert!(matches!(err, ExecutorError::TaskNotFound(_)));
3850    }
3851
3852    #[test]
3853    fn task_fault_state_starts_running() {
3854        use core::time::Duration;
3855        let mut exec = Executor::builder().worker_threads(0).build().unwrap();
3856        let task_id = exec
3857            .add(crate::item::item_with_triggers(
3858                |d| {
3859                    d.interval(Duration::from_millis(10));
3860                    Ok(())
3861                },
3862                |_| Ok(crate::ItemFlow::Continue),
3863            ))
3864            .unwrap();
3865        assert_eq!(exec.task_fault_state(task_id).unwrap(), FaultState::Running);
3866    }
3867
3868    #[test]
3869    fn executor_fault_state_starts_running() {
3870        let exec = Executor::builder().worker_threads(0).build().unwrap();
3871        assert_eq!(exec.executor_fault_state(), ExecutorFaultState::Running);
3872    }
3873
3874    // --- on_fatal / FatalDispatch integration tests ---
3875
3876    #[test]
3877    fn build_without_on_fatal_succeeds() {
3878        use crate::fatal::{FatalContext, FatalSite};
3879        use std::sync::{Arc, Mutex};
3880        // Default builder (no on_fatal) must build successfully.
3881        let exec = Executor::builder().worker_threads(0).build().unwrap();
3882        // The fatal_dispatch field is present; fire via a test terminal to
3883        // confirm the no-op handler doesn't blow up.
3884        let reached: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
3885        let reached2 = Arc::clone(&reached);
3886        let test_dispatch = crate::fatal::FatalDispatch::with_terminal(
3887            exec.fatal_dispatch.handler().clone(),
3888            move |_| {
3889                *reached2.lock().unwrap() = true;
3890            },
3891        );
3892        test_dispatch.fire(&FatalContext {
3893            cause: "test".to_string(),
3894            site: FatalSite::PoolWorker,
3895        });
3896        assert!(*reached.lock().unwrap(), "terminal not reached");
3897    }
3898
3899    #[test]
3900    fn on_fatal_handler_is_stored_and_invoked() {
3901        use crate::fatal::{FatalContext, FatalSite};
3902        use std::sync::{Arc, Mutex};
3903        let called: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
3904        let called2 = Arc::clone(&called);
3905        let exec = Executor::builder()
3906            .worker_threads(0)
3907            .on_fatal(move |ctx| {
3908                called2.lock().unwrap().push(ctx.cause.clone());
3909            })
3910            .build()
3911            .unwrap();
3912        // Verify the handler fires via a test terminal.
3913        let reached: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
3914        let reached2 = Arc::clone(&reached);
3915        let test_dispatch = crate::fatal::FatalDispatch::with_terminal(
3916            exec.fatal_dispatch.handler().clone(),
3917            move |_| {
3918                *reached2.lock().unwrap() = true;
3919            },
3920        );
3921        test_dispatch.fire(&FatalContext {
3922            cause: "my-cause".to_string(),
3923            site: FatalSite::ExecutorRunLoop,
3924        });
3925        assert!(*reached.lock().unwrap(), "terminal not reached");
3926        let log = called.lock().unwrap().clone();
3927        assert_eq!(
3928            log,
3929            vec!["my-cause"],
3930            "handler should have been called with cause"
3931        );
3932    }
3933}