metrale_core/
fault.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: Process-wide latch for a destroyed CUDA context, the probe-based verdict that sets it, and the exit status it maps to.
4//!
5//! [`classify`] decides from a probe result, not from the failing call's error
6//! code or text: the gpu-runtime fault probe (`cuda_backend/fault_probe.rs`)
7//! passes the result of `cuStreamSynchronize` on the default stream, issued
8//! after the failure. The latch is read by the request middleware
9//! (`gpu_fault_middleware`), `/health` and `/health/live`, the scheduler, and
10//! `main`'s exit path.
11//!
12//! Owner: core.
13//! Invariants:
14//! - A [`FaultLatch`] is set at most once, and keeps the first reason.
15//! - `is_faulted()` is true exactly when `fault()` is `Some`: both read the
16//!   same `OnceLock<String>`, so no reader sees one without the other.
17
18use std::sync::OnceLock;
19
20/// 2026-09-25: The verdict for one failed GPU operation.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Fatality {
23    /// 2026-09-25: The probe failed too: the context is unusable. The payload
24    /// is the operator-facing reason.
25    ContextLost(String),
26    /// 2026-09-25: The probe succeeded: the context still works.
27    Isolated,
28}
29
30/// 2026-09-25: Decide whether a failed GPU operation destroyed the context.
31///
32/// `probe` is the result of a call issued after the failure that succeeds on a
33/// healthy context. Only `probe` decides the verdict; `op` and `err` appear
34/// only in the reason text.
35pub fn classify(op: &str, err: &str, probe: Result<(), String>) -> Fatality {
36    match probe {
37        Ok(()) => Fatality::Isolated,
38        Err(probe_err) => Fatality::ContextLost(format!(
39            "{op} failed ({err}), and a no-op synchronize issued afterwards \
40             also failed ({probe_err}) — the CUDA context is destroyed. Errors \
41             of this class are sticky: every later driver call in this process \
42             returns the same status, so no request can be served."
43        )),
44    }
45}
46
47/// 2026-09-25: A one-shot, first-writer-wins fault flag.
48///
49/// Constructible on its own so tests need not latch the process-wide instance,
50/// which cannot be reset.
51#[derive(Debug, Default)]
52pub struct FaultLatch {
53    reason: OnceLock<String>,
54}
55
56impl FaultLatch {
57    pub const fn new() -> Self {
58        Self {
59            reason: OnceLock::new(),
60        }
61    }
62
63    /// 2026-09-25: Record a fatal fault. Returns `true` only for the call that
64    /// set the latch; later calls leave the first reason in place.
65    pub fn latch(&self, reason: impl Into<String>) -> bool {
66        self.reason.set(reason.into()).is_ok()
67    }
68
69    /// 2026-09-25: The reason for the fault, or `None` if healthy.
70    pub fn fault(&self) -> Option<&str> {
71        self.reason.get().map(String::as_str)
72    }
73
74    pub fn is_faulted(&self) -> bool {
75        self.reason.get().is_some()
76    }
77}
78
79static GLOBAL: FaultLatch = FaultLatch::new();
80
81/// 2026-09-25: The process-wide latch that the fault probe sets and the
82/// server reads.
83pub fn global() -> &'static FaultLatch {
84    &GLOBAL
85}
86
87/// 2026-09-25: Exit status of a process whose CUDA context was lost: nonzero,
88/// and distinct from the status 1 of an ordinary failure ([`exit_code`]).
89pub const EXIT_GPU_FAULT: i32 = 70;
90
91/// 2026-09-25: The process exit status for a run that is ending: a latched
92/// fault gives [`EXIT_GPU_FAULT`] whatever the run's own result, otherwise 0
93/// for success and 1 for failure. `main` exits through this, so a run that
94/// ended after a fault does not exit 0.
95pub fn exit_code(run_succeeded: bool, fault: Option<&str>) -> i32 {
96    match (run_succeeded, fault) {
97        (_, Some(_)) => EXIT_GPU_FAULT,
98        (true, None) => 0,
99        (false, None) => 1,
100    }
101}
102
103#[cfg(test)]
104#[path = "fault_tests.rs"]
105mod tests;