Skip to main content

taktora_executor/
fault.rs

1//! Cycle-overrun fault primitive — implements `FEAT_0018` (`REQ_0070`,
2//! `REQ_0071`, `REQ_0073`) and feeds `REQ_0102` (overrun counter).
3//!
4//! State machines are `AtomicU64`-packed so the dispatch hot path
5//! (`REQ_0060`, `REQ_0104`) can read/write them wait-free without
6//! `Mutex` or allocation.
7//!
8//! `BB_0093`.
9
10// The `FaultAtomic` / `ExecutorFaultAtomic` packed-atomic storage is
11// `pub(crate)` for use by `executor.rs` / `TaskEntry` in later tasks
12// (the cycle-overrun fault primitive lands in stages — BB_0093 ships
13// the state-machine module first; integration follows in Task 6+).
14// Until then, the storage types are unused — silence the dead-code
15// and redundant-pub-crate lints uniformly.
16#![allow(dead_code)]
17#![allow(clippy::redundant_pub_crate)]
18
19use core::sync::atomic::{AtomicU64, Ordering};
20use core::time::Duration;
21use std::time::Instant;
22
23/// Per-task fault state. Stored as packed `AtomicU64` in `TaskEntry`;
24/// the public API hands callers this snapshot view.
25///
26/// Not to be confused with [`ExecutorFaultState`], which is the
27/// executor-wide counterpart; this one is scoped to a single task.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum FaultState {
30    /// Task is healthy and dispatches normally.
31    Running,
32    /// Task is faulted; main item is not dispatched until cleared.
33    Faulted {
34        /// Why the task transitioned to `Faulted`.
35        reason: FaultReason,
36        /// Approximate transition time, executor-relative milliseconds.
37        /// Resolve to `Instant` via the executor's `start_time`.
38        since_ms: u32,
39    },
40}
41
42/// Why a task transitioned to `Faulted`.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum FaultReason {
45    /// The task's `execute()` ran longer than its declared budget.
46    /// `took_ms` is saturated to `u32::MAX` ms (~49.7 days).
47    BudgetExceeded {
48        /// Observed execution time in ms (saturated).
49        took_ms: u32,
50        /// Declared budget in ms (saturated).
51        budget_ms: u32,
52    },
53    /// The executor entered `Faulted` while this task was `Running`.
54    /// The cascade transition is automatic; per-task observers do
55    /// not fire (only `Observer::on_executor_fault` does).
56    ExecutorFaulted,
57}
58
59/// Executor-wide fault state.
60///
61/// Not to be confused with [`FaultState`], which is the per-task
62/// counterpart; this one spans the whole executor and halts all tasks.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum ExecutorFaultState {
65    /// Executor is healthy; all tasks dispatch normally.
66    Running,
67    /// Executor-wide budget breached. All tasks halt (or route to
68    /// their handlers) until cleared.
69    Faulted {
70        /// Why the executor transitioned to `Faulted`.
71        reason: ExecutorFaultReason,
72        /// Approximate transition time, executor-relative milliseconds.
73        since_ms: u32,
74    },
75}
76
77/// Why the executor transitioned to `Faulted`.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum ExecutorFaultReason {
80    /// A task's `execute()` exceeded the executor-wide iteration budget.
81    /// `task_idx` is the internal task-table index; resolve to `TaskId`
82    /// via the executor's task table.
83    IterationBudgetExceeded {
84        /// Internal index of the offending task in the executor's task vec.
85        task_idx: u32,
86        /// Observed execution time in ms (saturated).
87        took_ms: u32,
88        /// Declared executor-wide budget in ms (saturated).
89        budget_ms: u32,
90    },
91}
92
93/// Wait-free packed storage for `FaultState`.
94///
95/// Layout (bit positions, MSB to LSB):
96///   63..62 (2 bits)  — discriminant:
97///       0 = Running
98///       1 = Faulted{BudgetExceeded}
99///       2 = Faulted{ExecutorFaulted}
100///       3 = reserved
101///   61..32 (30 bits) — `took_ms` (saturated; 0 for `ExecutorFaulted`)
102///   31..0  (32 bits) — `since_ms`
103///
104/// `budget_ms` is stored separately (it's a static property of the task,
105/// not a runtime measurement), so it's reconstructed by the `unpack`
106/// caller from the task's stored `budget`.
107#[derive(Debug, Default)]
108pub(crate) struct FaultAtomic(AtomicU64);
109
110impl FaultAtomic {
111    /// Construct a fresh `FaultAtomic` in the `Running` state.
112    pub(crate) const fn new() -> Self {
113        Self(AtomicU64::new(0))
114    }
115
116    /// Pack `state` into a u64.
117    #[allow(clippy::cast_possible_truncation)]
118    pub(crate) fn pack(state: FaultState) -> u64 {
119        match state {
120            FaultState::Running => 0,
121            FaultState::Faulted { reason, since_ms } => {
122                let (disc, took_ms) = match reason {
123                    FaultReason::BudgetExceeded { took_ms, .. } => (1_u64, took_ms),
124                    FaultReason::ExecutorFaulted => (2_u64, 0_u32),
125                };
126                (disc << 62) | ((u64::from(took_ms) & 0x3FFF_FFFF) << 32) | u64::from(since_ms)
127            }
128        }
129    }
130
131    /// Recover `FaultState` from a packed u64 + the task's stored budget.
132    #[allow(clippy::cast_possible_truncation)]
133    pub(crate) const fn unpack(packed: u64, budget_ms: u32) -> FaultState {
134        let disc = (packed >> 62) & 0x3;
135        let took_ms = ((packed >> 32) & 0x3FFF_FFFF) as u32;
136        let since_ms = (packed & 0xFFFF_FFFF) as u32;
137        match disc {
138            1 => FaultState::Faulted {
139                reason: FaultReason::BudgetExceeded { took_ms, budget_ms },
140                since_ms,
141            },
142            2 => FaultState::Faulted {
143                reason: FaultReason::ExecutorFaulted,
144                since_ms,
145            },
146            // 0 = Running; 3 = reserved, treat as Running
147            _ => FaultState::Running,
148        }
149    }
150
151    /// Load the current state.
152    pub(crate) fn load(&self, budget_ms: u32) -> FaultState {
153        Self::unpack(self.0.load(Ordering::Acquire), budget_ms)
154    }
155
156    /// Store `state` and return the previous state. Callers use the
157    /// returned value to detect "first transition" (Observer callback
158    /// fires) vs "redundant store" (no callback).
159    pub(crate) fn swap(&self, state: FaultState, budget_ms: u32) -> FaultState {
160        Self::unpack(self.0.swap(Self::pack(state), Ordering::AcqRel), budget_ms)
161    }
162}
163
164/// Wait-free packed storage for `ExecutorFaultState`. Same shape as
165/// `FaultAtomic`; the offending task is stored as a u32 index in
166/// adjacent atomics (see `Executor` storage in Task 6).
167///
168/// Layout:
169///   63..62 (2 bits)  — discriminant:
170///       0 = Running
171///       1 = Faulted{IterationBudgetExceeded}
172///       2..3 = reserved
173///   61..32 (30 bits) — `took_ms` (saturated)
174///   31..0  (32 bits) — `since_ms`
175#[derive(Debug, Default)]
176pub(crate) struct ExecutorFaultAtomic(AtomicU64);
177
178impl ExecutorFaultAtomic {
179    /// Construct a fresh `ExecutorFaultAtomic` in the `Running` state.
180    pub(crate) const fn new() -> Self {
181        Self(AtomicU64::new(0))
182    }
183
184    /// Pack `state` into a u64.
185    #[allow(clippy::cast_possible_truncation)]
186    pub(crate) fn pack(state: ExecutorFaultState) -> u64 {
187        match state {
188            ExecutorFaultState::Running => 0,
189            ExecutorFaultState::Faulted { reason, since_ms } => {
190                let took_ms = match reason {
191                    ExecutorFaultReason::IterationBudgetExceeded { took_ms, .. } => took_ms,
192                };
193                (1_u64 << 62) | ((u64::from(took_ms) & 0x3FFF_FFFF) << 32) | u64::from(since_ms)
194            }
195        }
196    }
197
198    /// Recover `ExecutorFaultState`. `task_idx` and `budget_ms` are
199    /// supplied externally — they live in adjacent atomics next to
200    /// this one on the `Executor` (see Task 6).
201    #[allow(clippy::cast_possible_truncation)]
202    pub(crate) const fn unpack(packed: u64, task_idx: u32, budget_ms: u32) -> ExecutorFaultState {
203        let disc = (packed >> 62) & 0x3;
204        let took_ms = ((packed >> 32) & 0x3FFF_FFFF) as u32;
205        let since_ms = (packed & 0xFFFF_FFFF) as u32;
206        match disc {
207            1 => ExecutorFaultState::Faulted {
208                reason: ExecutorFaultReason::IterationBudgetExceeded {
209                    task_idx,
210                    took_ms,
211                    budget_ms,
212                },
213                since_ms,
214            },
215            // 0 = Running; 2..=3 = reserved, treat as Running
216            _ => ExecutorFaultState::Running,
217        }
218    }
219
220    /// Load the current state.
221    pub(crate) fn load(&self, task_idx: u32, budget_ms: u32) -> ExecutorFaultState {
222        Self::unpack(self.0.load(Ordering::Acquire), task_idx, budget_ms)
223    }
224
225    /// Store `state` and return the previous state.
226    pub(crate) fn swap(
227        &self,
228        state: ExecutorFaultState,
229        task_idx: u32,
230        budget_ms: u32,
231    ) -> ExecutorFaultState {
232        Self::unpack(
233            self.0.swap(Self::pack(state), Ordering::AcqRel),
234            task_idx,
235            budget_ms,
236        )
237    }
238}
239
240/// Helper: convert a `Duration` to ms, saturated to `u32::MAX`.
241#[allow(clippy::cast_possible_truncation)]
242pub(crate) fn duration_to_ms_sat(d: Duration) -> u32 {
243    let ms = d.as_millis();
244    if ms > u128::from(u32::MAX) {
245        u32::MAX
246    } else {
247        ms as u32
248    }
249}
250
251/// Helper: convert an executor-relative `Instant` to ms since start,
252/// saturated to `u32::MAX`.
253pub(crate) fn instant_to_since_ms(at: Instant, start: Instant) -> u32 {
254    duration_to_ms_sat(at.saturating_duration_since(start))
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn pack_unpack_running() {
263        let s = FaultState::Running;
264        let p = FaultAtomic::pack(s);
265        assert_eq!(p, 0);
266        assert_eq!(FaultAtomic::unpack(p, 100), FaultState::Running);
267    }
268
269    #[test]
270    fn pack_unpack_budget_exceeded_round_trip() {
271        let s = FaultState::Faulted {
272            reason: FaultReason::BudgetExceeded {
273                took_ms: 17,
274                budget_ms: 10,
275            },
276            since_ms: 1_234,
277        };
278        let p = FaultAtomic::pack(s);
279        // budget_ms is provided externally on unpack; pack stored took_ms only
280        let restored = FaultAtomic::unpack(p, 10);
281        assert_eq!(restored, s);
282    }
283
284    #[test]
285    fn pack_unpack_executor_faulted_round_trip() {
286        let s = FaultState::Faulted {
287            reason: FaultReason::ExecutorFaulted,
288            since_ms: u32::MAX,
289        };
290        let p = FaultAtomic::pack(s);
291        // budget_ms is unused for this discriminant
292        let restored = FaultAtomic::unpack(p, 999);
293        assert_eq!(restored, s);
294    }
295
296    #[test]
297    fn pack_unpack_saturates_took_ms() {
298        let s = FaultState::Faulted {
299            reason: FaultReason::BudgetExceeded {
300                took_ms: 0x3FFF_FFFF,
301                budget_ms: 5,
302            },
303            since_ms: 42,
304        };
305        let p = FaultAtomic::pack(s);
306        let restored = FaultAtomic::unpack(p, 5);
307        assert_eq!(restored, s);
308    }
309
310    #[test]
311    fn fault_atomic_swap_returns_previous() {
312        let fa = FaultAtomic::new();
313        assert_eq!(fa.load(0), FaultState::Running);
314        let prev = fa.swap(
315            FaultState::Faulted {
316                reason: FaultReason::BudgetExceeded {
317                    took_ms: 5,
318                    budget_ms: 3,
319                },
320                since_ms: 100,
321            },
322            3,
323        );
324        assert_eq!(prev, FaultState::Running);
325        let prev = fa.swap(FaultState::Running, 3);
326        assert!(matches!(
327            prev,
328            FaultState::Faulted {
329                reason: FaultReason::BudgetExceeded { .. },
330                ..
331            }
332        ));
333    }
334
335    #[test]
336    fn executor_fault_atomic_swap_returns_previous() {
337        let efa = ExecutorFaultAtomic::new();
338        assert_eq!(efa.load(0, 0), ExecutorFaultState::Running);
339        let prev = efa.swap(
340            ExecutorFaultState::Faulted {
341                reason: ExecutorFaultReason::IterationBudgetExceeded {
342                    task_idx: 3,
343                    took_ms: 20,
344                    budget_ms: 10,
345                },
346                since_ms: 50,
347            },
348            3,
349            10,
350        );
351        assert_eq!(prev, ExecutorFaultState::Running);
352    }
353
354    #[test]
355    fn duration_helpers_saturate() {
356        assert_eq!(duration_to_ms_sat(Duration::from_millis(5)), 5);
357        let huge = Duration::from_secs(60 * 60 * 24 * 365 * 100); // 100 years
358        assert_eq!(duration_to_ms_sat(huge), u32::MAX);
359    }
360}