atlas_core/
fault.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Process-fatal GPU fault latch.
4//!
5//! # Why this exists (issue #429)
6//!
7//! A large class of CUDA errors — `CUDA_ERROR_MISALIGNED_ADDRESS` (716),
8//! `CUDA_ERROR_ILLEGAL_ADDRESS` (700), `CUDA_ERROR_LAUNCH_FAILED` (719) — are
9//! **sticky**: they do not merely fail the call that produced them, they
10//! destroy the CUDA *context*. Every subsequent driver call in the process
11//! returns the same status, forever. There is no in-process recovery; the
12//! context cannot be re-created while the primary context is retained.
13//!
14//! Before this module, Atlas treated such a failure as a per-request error.
15//! The forward pass returned `Err`, the scheduler failed that batch, the
16//! handler emitted a 500 — and then **kept serving**. `/health` still said
17//! `ready`, because a model was still published, and every following request
18//! died deep in the driver (observed at `cuMemsetD8Async`). The process was
19//! alive, advertised itself as healthy, and could only produce errors.
20//!
21//! # How fatality is decided — probed, never guessed
22//!
23//! Classification does **not** match on the error code or its text. Sticky-ness
24//! is a property of the *context*, so it is measured directly: after a failed
25//! operation, issue a call that must succeed on a healthy context (a no-op
26//! synchronize). If that also fails, the context is gone.
27//!
28//! This is why [`classify`] takes a probe *result* rather than an error code.
29//! It buys three things a code allowlist cannot:
30//!
31//! - it covers every sticky status, including ones not yet enumerated;
32//! - it does **not** kill the server for a status that merely *looks* fatal —
33//!   an isolated `invalid argument` from a bad launch config leaves the
34//!   context healthy, and the probe says so;
35//! - it is a pure function of the probe, so both verdicts are unit-testable
36//!   with no GPU.
37//!
38//! # Contract
39//!
40//! The latch is one-shot and first-writer-wins: the *first* fault is the
41//! diagnostic one, and everything after it is that fault echoing through the
42//! remaining call sites. Reporting the tenth `cuMemsetD8Async` failure instead
43//! of the launch that poisoned the context would bury the cause.
44//!
45//! Both properties come from `OnceLock` rather than from code that maintains
46//! them. A flag-plus-reason pair (`AtomicBool` + `Mutex<Option<String>>`) has a
47//! window in which the flag is visible and the reason is not, and a health
48//! endpoint that lands in it reports "faulted, reason unknown" — the least
49//! useful of the three possible answers. A single `OnceLock<String>` makes
50//! "is faulted" and "has a reason" the same word, so the window does not
51//! exist and `set` supplies first-writer-wins atomically.
52
53use std::sync::OnceLock;
54
55/// The verdict for one failed GPU operation.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum Fatality {
58    /// The CUDA context is unusable. The process cannot recover and must be
59    /// drained and restarted; the payload is the operator-facing reason.
60    ContextLost(String),
61    /// The operation failed but the context still works. Fail the request,
62    /// keep the server.
63    Isolated,
64}
65
66/// Decide whether a failed GPU operation destroyed the context.
67///
68/// `probe` is the result of a call that **succeeds on any healthy context**,
69/// issued after the failure. `Err` from that probe is the evidence — no
70/// inference is drawn from `op` or `err`, which serve only to name the cause
71/// in the message.
72pub fn classify(op: &str, err: &str, probe: Result<(), String>) -> Fatality {
73    match probe {
74        Ok(()) => Fatality::Isolated,
75        Err(probe_err) => Fatality::ContextLost(format!(
76            "{op} failed ({err}), and a no-op synchronize issued afterwards \
77             also failed ({probe_err}) — the CUDA context is destroyed. Errors \
78             of this class are sticky: every later driver call in this process \
79             returns the same status, so no request can be served."
80        )),
81    }
82}
83
84/// A one-shot, first-writer-wins fault flag.
85///
86/// Constructible in a test so the global is never a shared fixture — a latch
87/// is by design irreversible, which would make one global instance a
88/// cross-test dependency.
89#[derive(Debug, Default)]
90pub struct FaultLatch {
91    reason: OnceLock<String>,
92}
93
94impl FaultLatch {
95    pub const fn new() -> Self {
96        Self {
97            reason: OnceLock::new(),
98        }
99    }
100
101    /// Record a fatal fault. Returns `true` iff this call was the first — the
102    /// caller uses that to log and to trigger shutdown exactly once.
103    pub fn latch(&self, reason: impl Into<String>) -> bool {
104        self.reason.set(reason.into()).is_ok()
105    }
106
107    /// The reason for the fault, or `None` if healthy.
108    pub fn fault(&self) -> Option<&str> {
109        self.reason.get().map(String::as_str)
110    }
111
112    /// Cheap health check — one acquire load.
113    pub fn is_faulted(&self) -> bool {
114        self.reason.get().is_some()
115    }
116}
117
118static GLOBAL: FaultLatch = FaultLatch::new();
119
120/// The process-wide latch. A destroyed CUDA context is a property of the
121/// process, not of any one backend handle, so this is deliberately global —
122/// a per-backend flag would report healthy from a second handle onto the same
123/// dead context.
124pub fn global() -> &'static FaultLatch {
125    &GLOBAL
126}
127
128/// Exit status for a process that died because its CUDA context was lost.
129///
130/// `70` is sysexits.h `EX_SOFTWARE`. The exact value matters far less than
131/// "not zero": supervisors gate restarts on it, and a distinct code lets an
132/// operator tell a poisoned-context death from an ordinary startup failure
133/// without reading logs.
134pub const EXIT_GPU_FAULT: i32 = 70;
135
136/// The process exit status for a run that is ending.
137///
138/// # Why this is not just `result`
139///
140/// A latched fault drains and shuts the server down **cleanly** — the accept
141/// loop returns `Ok(())` by exactly the same path it takes for `SIGTERM`. To a
142/// supervisor the two are then indistinguishable, so a server that lost its
143/// context exits `0`, `Restart=on-failure` (systemd) and `restart: on-failure`
144/// (Docker/Compose) both decline to restart it, and the endpoint stays down
145/// until a human notices. Every other part of the #429 response — the latch,
146/// the 503s, the drain — works and is undone by that one status code.
147///
148/// This is a known trap, not a hypothetical: vLLM shipped the same bug and
149/// fixed it in <https://github.com/vllm-project/vllm/issues/48966>, where
150/// exiting 0 made systemd log "successful deactivation" and stand down.
151pub fn exit_code(run_succeeded: bool, fault: Option<&str>) -> i32 {
152    match (run_succeeded, fault) {
153        // A fault outranks the run's own status: it is both the more specific
154        // cause and the reason the run ended at all.
155        (_, Some(_)) => EXIT_GPU_FAULT,
156        (true, None) => 0,
157        (false, None) => 1,
158    }
159}
160
161#[cfg(test)]
162#[path = "fault_tests.rs"]
163mod tests;