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}