Skip to main content

cugraph/
error.rs

1//! Errors carry a [`ErrorKind`] variant, an [`ErrorStatus`] recording the
2//! observed source condition, an operation name describing what was being
3//! attempted, an optional human-readable message, optional key-value context
4//! pairs, and an optional source error. This makes it easy to diagnose
5//! failures without losing the underlying cause. The status is the
6//! observation and does not grant permission to retry. Callers decide recovery
7//! only after cleanup and device-health checks.
8
9use std::{
10    backtrace::{Backtrace, BacktraceStatus},
11    error::Error as StdError,
12    ffi::{CStr, c_char},
13    fmt, ptr,
14};
15
16#[cfg(any(test, feature = "test-hooks"))]
17use cudf::testing::device_safety;
18use cudf::{
19    DeviceId,
20    runtime::{DeviceFaultKind, DeviceFaultNativeStatus, report_device_fault},
21};
22use cugraph_sys::{
23    cugraph_allocation_source_t, cugraph_error_alignment, cugraph_error_allocation_source, cugraph_error_code_t,
24    cugraph_error_cuda_error, cugraph_error_domain_id, cugraph_error_failure_source, cugraph_error_failure_source_t,
25    cugraph_error_free, cugraph_error_generation, cugraph_error_member_id, cugraph_error_message,
26    cugraph_error_requested_bytes, cugraph_error_t,
27};
28
29#[repr(C)]
30pub(crate) struct EdgeCountErrorDetail {
31    allocation_source: cugraph_allocation_source_t,
32    failure_source: cugraph_error_failure_source_t,
33    domain_id: u64,
34    member_id: u64,
35    generation: u64,
36    requested_bytes: u64,
37    alignment: u64,
38    cuda_error: i32,
39    message: [c_char; 512],
40}
41
42impl EdgeCountErrorDetail {
43    pub(crate) fn new() -> Self {
44        Self {
45            allocation_source: cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_UNKNOWN,
46            failure_source: cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_UNKNOWN,
47            domain_id: 0,
48            member_id: 0,
49            generation: 0,
50            requested_bytes: 0,
51            alignment: 0,
52            cuda_error: -1,
53            message: [0; 512],
54        }
55    }
56}
57
58const CUGRAPH_RS_CUDA_FAILURE_NONE: i32 = 0;
59const CUGRAPH_RS_CUDA_FAILURE_RECOVERABLE: i32 = 1;
60const CUGRAPH_RS_CUDA_FAILURE_FATAL: i32 = 2;
61
62unsafe extern "C" {
63    fn cugraph_rs_classify_cuda_failure_after_error(cuda_status: *mut i32) -> i32;
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum NativeCudaFailure {
68    None,
69    Recoverable(i32),
70    Fatal(i32),
71}
72
73/// Categorisation of cuGraph and library-level errors.
74///
75/// Each variant describes **what the caller should do**. It does not name the
76/// dependency that failed. Variants that originate in the raw C API map to the
77/// corresponding `CUGRAPH_*` error codes; library-only variants cover Rust-side
78/// interop, lifecycle, and I/O boundaries.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum ErrorKind {
82    /// An unexpected or unknown error occurred (`CUGRAPH_UNKNOWN_ERROR`).
83    Unexpected,
84    /// The resource handle is invalid (`CUGRAPH_INVALID_HANDLE`).
85    InvalidHandle,
86    /// GPU memory allocation failed (`CUGRAPH_ALLOC_ERROR`).
87    OutOfMemory,
88    /// An input argument was invalid (`CUGRAPH_INVALID_INPUT`).
89    InvalidInput,
90    /// An external I/O operation failed.
91    IoError,
92    /// A result invariant, lifecycle contract, or state transition failed (`CUGRAPH_INVALID_STATE`).
93    InvalidState,
94    /// The requested operation is not implemented (`CUGRAPH_NOT_IMPLEMENTED`).
95    NotImplemented,
96    /// The combination of types is not supported (`CUGRAPH_UNSUPPORTED_TYPE_COMBINATION`).
97    UnsupportedType,
98    /// An iterative algorithm reached its configured iteration bound.
99    NonConvergence,
100}
101
102impl ErrorKind {
103    /// Map a C error code to an [`ErrorKind`].
104    pub fn from_error_code(code: cugraph_error_code_t) -> Self {
105        match code {
106            cugraph_error_code_t::CUGRAPH_INVALID_HANDLE => ErrorKind::InvalidHandle,
107            cugraph_error_code_t::CUGRAPH_ALLOC_ERROR => ErrorKind::OutOfMemory,
108            cugraph_error_code_t::CUGRAPH_INVALID_INPUT => ErrorKind::InvalidInput,
109            cugraph_error_code_t::CUGRAPH_INVALID_STATE => ErrorKind::InvalidState,
110            cugraph_error_code_t::CUGRAPH_NOT_IMPLEMENTED => ErrorKind::NotImplemented,
111            cugraph_error_code_t::CUGRAPH_UNSUPPORTED_TYPE_COMBINATION => ErrorKind::UnsupportedType,
112            _ => ErrorKind::Unexpected,
113        }
114    }
115
116    /// Map from a raw C error-code integer value.
117    pub fn from_error_code_raw(raw: u32) -> Self {
118        Self::from_error_code(cugraph_error_code_t(raw))
119    }
120
121    /// Whether a backtrace should be captured for this error kind.
122    ///
123    /// Only truly unexpected errors benefit from backtraces; expected
124    /// conditions like `InvalidInput` or `NotImplemented` have sufficient
125    /// context from the error kind and message alone.
126    fn capture_backtrace(&self) -> bool {
127        matches!(self, ErrorKind::Unexpected)
128    }
129}
130
131impl fmt::Display for ErrorKind {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            ErrorKind::Unexpected => write!(f, "Unexpected"),
135            ErrorKind::InvalidHandle => write!(f, "InvalidHandle"),
136            ErrorKind::OutOfMemory => write!(f, "OutOfMemory"),
137            ErrorKind::InvalidInput => write!(f, "InvalidInput"),
138            ErrorKind::IoError => write!(f, "IoError"),
139            ErrorKind::InvalidState => write!(f, "InvalidState"),
140            ErrorKind::NotImplemented => write!(f, "NotImplemented"),
141            ErrorKind::UnsupportedType => write!(f, "UnsupportedType"),
142            ErrorKind::NonConvergence => write!(f, "NonConvergence"),
143        }
144    }
145}
146
147/// Which allocation-failure exception type produced a cuGraph allocation error.
148///
149/// Records the C++ exception type caught by cuGraph. Because `rmm::out_of_memory`
150/// derives from `rmm::bad_alloc` and `std::bad_alloc`, the most-derived matching
151/// variant is reported. `Unknown` covers non-allocation errors, missing error
152/// objects, and unrecognized native values.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
154pub enum AllocationSource {
155    /// The C error object did not explicitly report a known allocation path.
156    #[default]
157    Unknown,
158    /// `rmm::out_of_memory`.
159    RmmOutOfMemory,
160    /// `rmm::bad_alloc`.
161    RmmBadAlloc,
162    /// `std::bad_alloc`.
163    StdBadAlloc,
164}
165
166impl AllocationSource {
167    fn from_native(value: cugraph_allocation_source_t) -> Self {
168        match value {
169            cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_RMM_OUT_OF_MEMORY => Self::RmmOutOfMemory,
170            cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_RMM_BAD_ALLOC => Self::RmmBadAlloc,
171            cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_STD_BAD_ALLOC => Self::StdBadAlloc,
172            _ => Self::Unknown,
173        }
174    }
175
176    unsafe fn from_error(error: *const cugraph_error_t) -> Self {
177        // SAFETY: the caller guarantees that `error` is either null or a live
178        // cuGraph error object for the duration of this call.
179        let value = unsafe { cugraph_error_allocation_source(error) };
180        Self::from_native(value)
181    }
182
183    /// Returns the stable fact value for this allocation source.
184    pub const fn as_str(self) -> &'static str {
185        match self {
186            Self::Unknown => "unknown",
187            Self::RmmOutOfMemory => "rmm_out_of_memory",
188            Self::RmmBadAlloc => "rmm_bad_alloc",
189            Self::StdBadAlloc => "std_bad_alloc",
190        }
191    }
192}
193
194impl fmt::Display for AllocationSource {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.write_str(self.as_str())
197    }
198}
199
200/// Which owner or device condition caused an allocation failure.
201///
202/// This is separate from [`AllocationSource`], which records only the C++
203/// exception type caught at the native boundary. `Unknown` is fail-closed and
204/// must not be inferred from an error message or from `ErrorStatus`.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
206pub enum AllocationFailureSource {
207    /// The native error did not carry an attributable failure source.
208    #[default]
209    Unknown,
210    /// The query or owner reservation limit rejected the allocation.
211    ReservationLimit,
212    /// The device allocator reported physical memory pressure.
213    Physical,
214}
215
216impl AllocationFailureSource {
217    fn from_native(value: cugraph_error_failure_source_t) -> Self {
218        match value {
219            cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_PHYSICAL => Self::Physical,
220            cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_RESERVATION_LIMIT => Self::ReservationLimit,
221            _ => Self::Unknown,
222        }
223    }
224
225    unsafe fn from_error(error: *const cugraph_error_t) -> Self {
226        // SAFETY: the caller guarantees that `error` is either null or a live
227        // cuGraph error object for the duration of this call.
228        let value = unsafe { cugraph_error_failure_source(error) };
229        Self::from_native(value)
230    }
231
232    /// Returns the stable fact value for this failure source.
233    pub const fn as_str(self) -> &'static str {
234        match self {
235            Self::Unknown => "unknown",
236            Self::ReservationLimit => "reservation_limit",
237            Self::Physical => "physical",
238        }
239    }
240}
241
242impl fmt::Display for AllocationFailureSource {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        f.write_str(self.as_str())
245    }
246}
247
248/// Source condition recorded on an error.
249///
250/// Callers evaluate retry viability after verifying device health and
251/// completing cleanup:
252///
253/// - [`Permanent`](ErrorStatus::Permanent): will never succeed without
254///   external changes (e.g. invalid input, unsupported operation). Do not
255///   retry the same request.
256/// - [`Temporary`](ErrorStatus::Temporary): transient condition that may
257///   clear (e.g. GPU memory pressure, recoverable CUDA failure). Retry only
258///   after cleanup and a health check.
259/// - [`Persistent`](ErrorStatus::Persistent): the source reports a
260///   continuing condition (e.g. a fatal CUDA probe result, recorded without
261///   any retry having happened). Stop automatic retry, finish cleanup, and
262///   escalate.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
264pub enum ErrorStatus {
265    /// The error is permanent; retrying will not help without external
266    /// intervention.
267    Permanent,
268    /// The source condition is transient and may clear. Retry remains the
269    /// caller's decision after cleanup and device-health checks.
270    Temporary,
271    /// The source reports a continuing condition. It may be set directly by
272    /// a fatal probe result without any retry having happened; stop
273    /// automatic retry, finish cleanup, and escalate.
274    Persistent,
275}
276
277impl fmt::Display for ErrorStatus {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        match self {
280            ErrorStatus::Permanent => write!(f, "permanent"),
281            ErrorStatus::Temporary => write!(f, "temporary"),
282            ErrorStatus::Persistent => write!(f, "persistent"),
283        }
284    }
285}
286
287/// A cuGraph error with structured context.
288///
289/// Carries the kind of error, retry status, the operation that was being
290/// performed, an optional human-readable message, optional key-value context
291/// pairs for debugging, and an optional source error.
292///
293/// # Display formats
294///
295/// - **`Display`** (single-line): for structured logs and machine parsing.
296///   Format: `{kind} ({status}) at {operation}[ => {message}][, context: { ... }][, source: ...]`
297/// - **`Debug`** (multi-line): for human debugging with context breakdown.
298/// - **`Debug` alternate** (`{:#?}`): standard Rust struct-style inspection.
299#[derive(Debug, Clone, Copy)]
300struct AllocationFacts {
301    source: AllocationSource,
302    failure_source: AllocationFailureSource,
303    domain_id: u64,
304    member_id: u64,
305    generation: u64,
306    requested_bytes: u64,
307    alignment: u64,
308    cuda_error: i32,
309}
310
311impl Default for AllocationFacts {
312    fn default() -> Self {
313        Self {
314            source: AllocationSource::Unknown,
315            failure_source: AllocationFailureSource::Unknown,
316            domain_id: 0,
317            member_id: 0,
318            generation: 0,
319            requested_bytes: 0,
320            alignment: 0,
321            cuda_error: -1,
322        }
323    }
324}
325
326pub struct Error {
327    kind: ErrorKind,
328    status: ErrorStatus,
329    allocation: Option<Box<AllocationFacts>>,
330    operation: &'static str,
331    message: String,
332    context: Vec<(&'static str, String)>,
333    source: Option<Box<dyn StdError + Send + Sync + 'static>>,
334    backtrace: Option<Box<Backtrace>>,
335}
336
337impl Error {
338    /// Create a new error for the given kind and operation.
339    ///
340    /// The error defaults to [`ErrorStatus::Permanent`]. A backtrace is
341    /// captured only for [`ErrorKind::Unexpected`] errors (when the
342    /// `RUST_BACKTRACE` environment variable is set).
343    pub fn new(kind: ErrorKind, operation: &'static str) -> Self {
344        Self {
345            kind,
346            status: ErrorStatus::Permanent,
347            allocation: None,
348            operation,
349            message: String::new(),
350            context: Vec::new(),
351            source: None,
352            backtrace: kind
353                .capture_backtrace()
354                .then(Backtrace::capture)
355                .filter(|bt| bt.status() == BacktraceStatus::Captured)
356                .map(Box::new),
357        }
358    }
359
360    fn allocation_facts(&self) -> AllocationFacts {
361        self.allocation.as_deref().copied().unwrap_or_default()
362    }
363
364    /// Set the human-readable error message.
365    pub fn with_message(mut self, message: impl Into<String>) -> Self {
366        self.message = message.into();
367        self
368    }
369
370    /// Set the operation, stacking the previous one as context.
371    ///
372    /// If an operation is already set, it is preserved as
373    /// `("called", old_operation)` context, creating a logical call chain
374    /// for debugging.
375    pub fn with_operation(mut self, operation: &'static str) -> Self {
376        if !self.operation.is_empty() {
377            self.context.push(("called", self.operation.to_string()));
378        }
379        self.operation = operation;
380        self
381    }
382
383    /// Attach a key-value context pair for debugging.
384    pub fn with_context(mut self, key: &'static str, value: impl Into<String>) -> Self {
385        self.context.push((key, value.into()));
386        self
387    }
388
389    /// Attach an underlying source error.
390    ///
391    /// Accepts any source error that is safe to store and report from this
392    /// library's public error type.
393    pub fn set_source<E>(mut self, source: E) -> Self
394    where E: StdError + Send + Sync + 'static {
395        debug_assert!(self.source.is_none(), "the source error has been set");
396        self.source = Some(Box::new(source));
397        self
398    }
399
400    /// Record a transient source condition.
401    ///
402    /// This marks the observation only. Retry remains the caller's decision
403    /// after its cleanup and device-health checks.
404    pub fn set_temporary(mut self) -> Self {
405        self.status = ErrorStatus::Temporary;
406        self
407    }
408
409    /// Record a permanent source condition: retrying the same request will
410    /// not help without external changes.
411    pub fn set_permanent(mut self) -> Self {
412        self.status = ErrorStatus::Permanent;
413        self
414    }
415
416    /// Record a continuing source condition.
417    ///
418    /// A fatal probe result can set this status directly, without any retry
419    /// having happened. Callers must stop automatic retry, finish cleanup,
420    /// and escalate.
421    pub fn set_persistent(mut self) -> Self {
422        self.status = ErrorStatus::Persistent;
423        self
424    }
425
426    /// The kind of this error.
427    pub fn kind(&self) -> ErrorKind {
428        self.kind
429    }
430
431    /// The retry status of this error.
432    pub fn status(&self) -> ErrorStatus {
433        self.status
434    }
435
436    /// Which allocation-failure exception type produced this error.
437    ///
438    /// This is `Unknown` unless the C error object explicitly classified an
439    /// allocation failure. It reports only the caught exception type and is
440    /// never inferred from message text or error status.
441    pub fn allocation_source(&self) -> AllocationSource {
442        self.allocation_facts().source
443    }
444
445    /// Which owner or device condition caused this allocation failure.
446    pub fn allocation_failure_source(&self) -> AllocationFailureSource {
447        self.allocation_facts().failure_source
448    }
449
450    /// Attributes this error to an allocation failure source.
451    ///
452    /// Native code attributes allocation failures from the C error object.
453    /// This constructor lets consumers exercise the attribution contract
454    /// without a device.
455    #[cfg(any(test, feature = "test-hooks"))]
456    #[must_use]
457    pub fn with_allocation_failure_source(mut self, failure_source: AllocationFailureSource) -> Self {
458        let mut facts = self.allocation_facts();
459        facts.failure_source = failure_source;
460        self.allocation = Some(Box::new(facts));
461        self
462    }
463
464    /// Stable domain identity supplied by the native allocation owner.
465    pub fn allocation_domain_id(&self) -> u64 {
466        self.allocation_facts().domain_id
467    }
468
469    /// Stable member identity supplied by the native allocation owner.
470    pub fn allocation_member_id(&self) -> u64 {
471        self.allocation_facts().member_id
472    }
473
474    /// Native generation for the allocation owner identity.
475    pub fn allocation_generation(&self) -> u64 {
476        self.allocation_facts().generation
477    }
478
479    /// Bytes requested by the failed allocation, when known.
480    pub fn allocation_requested_bytes(&self) -> u64 {
481        self.allocation_facts().requested_bytes
482    }
483
484    /// Alignment requested by the failed allocation, when known.
485    pub fn allocation_alignment(&self) -> u64 {
486        self.allocation_facts().alignment
487    }
488
489    /// CUDA status observed at the allocation boundary, or `-1` when absent.
490    pub fn allocation_cuda_error(&self) -> i32 {
491        self.allocation_facts().cuda_error
492    }
493
494    /// Whether the source reported a transient condition.
495    ///
496    /// This is the observation and does not permit a retry. The caller must
497    /// still complete cleanup and device-health checks first.
498    pub fn is_temporary(&self) -> bool {
499        self.status == ErrorStatus::Temporary
500    }
501
502    /// Whether the source reported a permanent condition.
503    pub fn is_permanent(&self) -> bool {
504        self.status == ErrorStatus::Permanent
505    }
506
507    /// Whether the source reported a continuing condition.
508    ///
509    /// This status can be set directly by a fatal probe result without any
510    /// retry having happened.
511    pub fn is_persistent(&self) -> bool {
512        self.status == ErrorStatus::Persistent
513    }
514
515    /// The operation that was being performed when the error occurred.
516    pub fn operation(&self) -> &'static str {
517        self.operation
518    }
519
520    /// The human-readable error message, if any.
521    pub fn message(&self) -> &str {
522        &self.message
523    }
524
525    /// Returns one structured context value by key.
526    pub fn context_value(&self, key: &str) -> Option<&str> {
527        self.context.iter().rev().find(|(candidate, _)| *candidate == key).map(|(_, value)| value.as_str())
528    }
529}
530
531impl fmt::Debug for Error {
532    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533        if f.alternate() {
534            // {:#?}; standard Rust struct-style inspection.
535            let allocation = self.allocation_facts();
536            let mut d = f.debug_struct("Error");
537            d.field("kind", &self.kind);
538            d.field("status", &self.status);
539            d.field("allocation_source", &allocation.source);
540            d.field("allocation_failure_source", &allocation.failure_source);
541            d.field("allocation_domain_id", &allocation.domain_id);
542            d.field("allocation_member_id", &allocation.member_id);
543            d.field("allocation_generation", &allocation.generation);
544            d.field("allocation_requested_bytes", &allocation.requested_bytes);
545            d.field("allocation_alignment", &allocation.alignment);
546            d.field("allocation_cuda_error", &allocation.cuda_error);
547            d.field("operation", &self.operation);
548            if !self.message.is_empty() {
549                d.field("message", &self.message);
550            }
551            if !self.context.is_empty() {
552                d.field("context", &self.context);
553            }
554            if let Some(ref src) = self.source {
555                d.field("source", src);
556            }
557            d.finish()
558        } else {
559            // {:?}; multi-line format for human debugging.
560            write!(f, "{} ({}) at {}", self.kind, self.status, self.operation)?;
561            if !self.message.is_empty() {
562                write!(f, " => {}", self.message)?;
563            }
564            if !self.context.is_empty() {
565                write!(f, "\n\nContext:")?;
566                for (key, value) in &self.context {
567                    write!(f, "\n   {}: {}", key, value)?;
568                }
569            }
570            if let Some(ref src) = self.source {
571                write!(f, "\n\nSource: {:#}", src)?;
572            }
573            if let Some(ref bt) = self.backtrace {
574                write!(f, "\n\nBacktrace:\n{}", bt)?;
575            }
576            Ok(())
577        }
578    }
579}
580
581impl fmt::Display for Error {
582    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
583        write!(f, "{} ({}) at {}", self.kind, self.status, self.operation)?;
584        if !self.message.is_empty() {
585            write!(f, " => {}", self.message)?;
586        }
587        if !self.context.is_empty() {
588            write!(f, ", context: {{")?;
589            for (i, (key, value)) in self.context.iter().enumerate() {
590                if i > 0 {
591                    write!(f, ",")?;
592                }
593                write!(f, " {}: {}", key, value)?;
594            }
595            write!(f, " }}")?;
596        }
597        if let Some(ref src) = self.source {
598            write!(f, ", source: {}", src)?;
599        }
600        Ok(())
601    }
602}
603
604impl std::error::Error for Error {
605    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
606        self.source.as_ref().map(|e| e.as_ref() as &(dyn StdError + 'static))
607    }
608}
609
610/// Convenience type alias.
611pub type Result<T> = std::result::Result<T, Error>;
612
613pub(crate) fn unexpected_null_pointer(operation: &'static str, pointer_name: &'static str) -> Error {
614    Error::new(ErrorKind::Unexpected, operation)
615        .with_message(format!("C API returned null pointer for {pointer_name}"))
616        .with_context("pointer", pointer_name)
617}
618
619pub(crate) fn require_non_null<T>(ptr: *mut T, operation: &'static str, pointer_name: &'static str) -> Result<*mut T> {
620    if ptr.is_null() { Err(unexpected_null_pointer(operation, pointer_name)) } else { Ok(ptr) }
621}
622
623/// Check a GPU-capable C API return code and convert to a Rust `Result`.
624///
625/// On success (`CUGRAPH_SUCCESS`), returns `Ok(())`. Otherwise extracts the
626/// error message from the C error object, frees it, and returns an appropriate
627/// [`Error`].
628///
629/// `OutOfMemory` errors are automatically marked as
630/// [`ErrorStatus::Temporary`] since GPU memory pressure is often transient.
631///
632/// # Safety
633///
634/// `error_ptr` must be null or a valid `cugraph_error_t` populated by the C API
635/// call. The caller must still hold owner-device entry for `device` so the
636/// native typed CUDA failure probe inspects the same immutable device binding.
637pub(crate) unsafe fn check_error(
638    code: cugraph_error_code_t,
639    error_ptr: *mut cugraph_error_t,
640    device: i32,
641    operation: &'static str,
642) -> Result<()> {
643    if code == cugraph_error_code_t::CUGRAPH_SUCCESS {
644        return Ok(());
645    }
646
647    // Copy every field while the C error object is still live. The CUDA
648    // classification probe below is another FFI call and must not be allowed
649    // to race a native payload that is only valid until the next call.
650    // SAFETY: the native call leaves either a null pointer or a live error
651    // object until this function frees it after copying the payload.
652    let payload = unsafe { copy_native_error_payload(error_ptr) };
653    // SAFETY: owner-device entry remains live and the native probe accepts no
654    // borrowed resource other than its valid stack out-pointer.
655    let cuda_failure = unsafe { native_cuda_failure_after_error() };
656    // SAFETY: this forwards the caller's error-pointer contract and consumes
657    // the pointer exactly once on the failure path.
658    unsafe { check_error_with_payload(code, error_ptr, payload, Some((device, cuda_failure)), operation) }
659}
660
661/// Convert the crate-owned edge-count boundary without constructing an upstream error owner.
662///
663/// # Safety
664///
665/// `detail` must be filled by the local shim, with a NUL-terminated message. The
666/// caller must retain owner-device entry until CUDA classification completes.
667pub(crate) unsafe fn check_edge_count_error(
668    code: cugraph_error_code_t,
669    detail: &EdgeCountErrorDetail,
670    device: i32,
671    operation: &'static str,
672) -> Result<()> {
673    if code == cugraph_error_code_t::CUGRAPH_SUCCESS {
674        return Ok(());
675    }
676    let payload = NativeErrorPayload {
677        allocation_source: AllocationSource::from_native(detail.allocation_source),
678        allocation_failure_source: AllocationFailureSource::from_native(detail.failure_source),
679        domain_id: detail.domain_id,
680        member_id: detail.member_id,
681        generation: detail.generation,
682        requested_bytes: detail.requested_bytes,
683        alignment: detail.alignment,
684        cuda_error: detail.cuda_error,
685        // SAFETY: the shim owns and NUL-terminates the fixed message buffer.
686        message: unsafe { CStr::from_ptr(detail.message.as_ptr()) }.to_string_lossy().into_owned(),
687    };
688    // SAFETY: owner-device entry is retained for the native CUDA probe and no upstream error owner exists.
689    unsafe {
690        let cuda_failure = native_cuda_failure_after_error();
691        check_error_with_payload(code, ptr::null_mut(), payload, Some((device, cuda_failure)), operation)
692    }
693}
694
695/// Check a host-only cuGraph C API call that cannot execute CUDA work.
696///
697/// # Safety
698///
699/// `error_ptr` must be null or a valid `cugraph_error_t` populated by the C API
700/// call and consumed exactly once by this function on failure.
701pub(crate) unsafe fn check_host_error(
702    code: cugraph_error_code_t,
703    error_ptr: *mut cugraph_error_t,
704    operation: &'static str,
705) -> Result<()> {
706    if code == cugraph_error_code_t::CUGRAPH_SUCCESS {
707        return Ok(());
708    }
709    // SAFETY: the native call leaves either a null pointer or a live error
710    // object until this function frees it after copying the payload.
711    let payload = unsafe { copy_native_error_payload(error_ptr) };
712    // SAFETY: this forwards the caller's error-pointer contract and consumes
713    // the pointer exactly once on the failure path.
714    unsafe { check_error_with_payload(code, error_ptr, payload, None, operation) }
715}
716
717unsafe fn native_cuda_failure_after_error() -> NativeCudaFailure {
718    let mut cuda_status = 0;
719    // SAFETY: `cuda_status` is a valid writable out-pointer and the shim does
720    // not retain it after returning.
721    let classification = unsafe { cugraph_rs_classify_cuda_failure_after_error(&mut cuda_status) };
722    match classification {
723        CUGRAPH_RS_CUDA_FAILURE_NONE => NativeCudaFailure::None,
724        CUGRAPH_RS_CUDA_FAILURE_FATAL => NativeCudaFailure::Fatal(cuda_status),
725        CUGRAPH_RS_CUDA_FAILURE_RECOVERABLE => NativeCudaFailure::Recoverable(cuda_status),
726        _ => NativeCudaFailure::Recoverable(cuda_status),
727    }
728}
729
730struct NativeErrorPayload {
731    allocation_source: AllocationSource,
732    allocation_failure_source: AllocationFailureSource,
733    domain_id: u64,
734    member_id: u64,
735    generation: u64,
736    requested_bytes: u64,
737    alignment: u64,
738    cuda_error: i32,
739    message: String,
740}
741
742impl NativeErrorPayload {
743    fn unknown() -> Self {
744        Self {
745            allocation_source: AllocationSource::Unknown,
746            allocation_failure_source: AllocationFailureSource::Unknown,
747            domain_id: 0,
748            member_id: 0,
749            generation: 0,
750            requested_bytes: 0,
751            alignment: 0,
752            cuda_error: -1,
753            message: String::new(),
754        }
755    }
756}
757
758unsafe fn copy_native_error_payload(error: *const cugraph_error_t) -> NativeErrorPayload {
759    if error.is_null() {
760        return NativeErrorPayload::unknown();
761    }
762
763    // SAFETY: the caller guarantees that `error` is live until this complete
764    // owned copy finishes.
765    let message = unsafe { cugraph_error_message(error) };
766    let message = if message.is_null() {
767        String::new()
768    } else {
769        // SAFETY: cuGraph returns a NUL-terminated string owned by `error`.
770        unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned()
771    };
772
773    // SAFETY: `error` is non-null and live for these accessors. Each call
774    // reads the native object without retaining the pointer.
775    let (
776        allocation_source,
777        allocation_failure_source,
778        domain_id,
779        member_id,
780        generation,
781        requested_bytes,
782        alignment,
783        cuda_error,
784    ) = unsafe {
785        (
786            AllocationSource::from_error(error),
787            AllocationFailureSource::from_error(error),
788            cugraph_error_domain_id(error),
789            cugraph_error_member_id(error),
790            cugraph_error_generation(error),
791            cugraph_error_requested_bytes(error),
792            cugraph_error_alignment(error),
793            cugraph_error_cuda_error(error),
794        )
795    };
796
797    NativeErrorPayload {
798        allocation_source,
799        allocation_failure_source,
800        domain_id,
801        member_id,
802        generation,
803        requested_bytes,
804        alignment,
805        cuda_error,
806        message,
807    }
808}
809
810unsafe fn check_error_with_payload(
811    code: cugraph_error_code_t,
812    error_ptr: *mut cugraph_error_t,
813    payload: NativeErrorPayload,
814    device_failure: Option<(i32, NativeCudaFailure)>,
815    operation: &'static str,
816) -> Result<()> {
817    let kind = ErrorKind::from_error_code(code);
818    let mut err = Error::new(kind, operation);
819    err.allocation = Some(Box::new(AllocationFacts {
820        source: payload.allocation_source,
821        failure_source: payload.allocation_failure_source,
822        domain_id: payload.domain_id,
823        member_id: payload.member_id,
824        generation: payload.generation,
825        requested_bytes: payload.requested_bytes,
826        alignment: payload.alignment,
827        cuda_error: payload.cuda_error,
828    }));
829
830    // OutOfMemory is typically transient; GPU memory may free up.
831    if kind == ErrorKind::OutOfMemory {
832        err = err.set_temporary();
833    }
834
835    if let Some((device, cuda_failure)) = device_failure {
836        match cuda_failure {
837            NativeCudaFailure::None => {},
838            NativeCudaFailure::Recoverable(cuda_status) => {
839                err = err
840                    .with_context("cuda_failure_classification", "recoverable")
841                    .with_context("cuda_status_code", cuda_status.to_string());
842            },
843            NativeCudaFailure::Fatal(cuda_status) => {
844                let physical = usize::try_from(device).ok().and_then(|ordinal| DeviceId::new(ordinal).ok());
845                #[cfg(any(test, feature = "test-hooks"))]
846                let physical = physical.or_else(|| device_safety::injected_device_id(device));
847                if let Some(device_id) = physical {
848                    report_device_fault(
849                        device_id,
850                        DeviceFaultKind::FatalDeviceOperation,
851                        operation,
852                        Some(DeviceFaultNativeStatus::Cuda(cuda_status)),
853                    );
854                }
855                err = err
856                    .set_persistent()
857                    .with_context("cuda_failure_classification", "fatal")
858                    .with_context("cuda_status_code", cuda_status.to_string())
859                    .with_context("device", device.to_string());
860            },
861        }
862    }
863
864    if !payload.message.is_empty() {
865        err = err.with_message(payload.message);
866    }
867
868    if !error_ptr.is_null() {
869        // SAFETY: `error_ptr` is a live cuGraph error object and this function
870        // consumes it exactly once on the failure path. Upstream owns only a
871        // host `std::string`; no CUDA resource is touched by this destructor.
872        unsafe { cugraph_error_free(error_ptr) };
873    }
874
875    Err(err)
876}
877
878#[cfg(test)]
879mod tests {
880    use cudf::runtime::DeviceHealthStatus;
881
882    use super::*;
883
884    #[test]
885    fn allocation_source_maps_native_values_and_fails_closed() {
886        assert_eq!(
887            AllocationSource::from_native(cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_UNKNOWN),
888            AllocationSource::Unknown
889        );
890        assert_eq!(
891            AllocationSource::from_native(cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_RMM_OUT_OF_MEMORY),
892            AllocationSource::RmmOutOfMemory
893        );
894        assert_eq!(
895            AllocationSource::from_native(cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_RMM_BAD_ALLOC),
896            AllocationSource::RmmBadAlloc
897        );
898        assert_eq!(
899            AllocationSource::from_native(cugraph_allocation_source_t::CUGRAPH_ALLOCATION_SOURCE_STD_BAD_ALLOC),
900            AllocationSource::StdBadAlloc
901        );
902        assert_eq!(AllocationSource::from_native(cugraph_allocation_source_t(999)), AllocationSource::Unknown);
903    }
904
905    #[test]
906    fn allocation_failure_source_maps_native_values_and_fails_closed() {
907        assert_eq!(
908            AllocationFailureSource::from_native(cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_PHYSICAL),
909            AllocationFailureSource::Physical
910        );
911        assert_eq!(
912            AllocationFailureSource::from_native(
913                cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_RESERVATION_LIMIT
914            ),
915            AllocationFailureSource::ReservationLimit
916        );
917        assert_eq!(
918            AllocationFailureSource::from_native(cugraph_error_failure_source_t::CUGRAPH_ERROR_FAILURE_SOURCE_UNKNOWN),
919            AllocationFailureSource::Unknown
920        );
921        assert_eq!(
922            AllocationFailureSource::from_native(cugraph_error_failure_source_t(999)),
923            AllocationFailureSource::Unknown
924        );
925    }
926
927    #[test]
928    fn test_error_kind_from_error_code() {
929        assert_eq!(ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_INVALID_HANDLE), ErrorKind::InvalidHandle);
930        assert_eq!(ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_ALLOC_ERROR), ErrorKind::OutOfMemory);
931        assert_eq!(ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_INVALID_INPUT), ErrorKind::InvalidInput);
932        assert_eq!(ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_INVALID_STATE), ErrorKind::InvalidState);
933        assert_eq!(
934            ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_NOT_IMPLEMENTED),
935            ErrorKind::NotImplemented
936        );
937        assert_eq!(
938            ErrorKind::from_error_code(cugraph_error_code_t::CUGRAPH_UNSUPPORTED_TYPE_COMBINATION),
939            ErrorKind::UnsupportedType
940        );
941    }
942
943    #[test]
944    fn test_error_display_with_message_and_context() {
945        let err = Error::new(ErrorKind::InvalidInput, "GraphBuilder::build")
946            .with_message("src and dst must have the same length")
947            .with_context("src_len", "10")
948            .with_context("dst_len", "5");
949
950        let display = err.to_string();
951        assert_eq!(
952            display,
953            "InvalidInput (permanent) at GraphBuilder::build \
954             => src and dst must have the same length, \
955             context: { src_len: 10, dst_len: 5 }"
956        );
957    }
958
959    #[test]
960    fn test_error_source_chain() {
961        let source = std::io::Error::other("GPU fault");
962        let err = Error::new(ErrorKind::Unexpected, "ResourceHandle::new").set_source(source);
963
964        let std_source = std::error::Error::source(&err);
965        assert!(std_source.is_some());
966        assert_eq!(std_source.unwrap().to_string(), "GPU fault");
967    }
968
969    #[test]
970    fn native_payload_preserves_allocation_facts_and_error_identity() {
971        let payload = NativeErrorPayload {
972            allocation_source: AllocationSource::RmmOutOfMemory,
973            allocation_failure_source: AllocationFailureSource::ReservationLimit,
974            domain_id: 11,
975            member_id: 17,
976            generation: 23,
977            requested_bytes: 4096,
978            alignment: 256,
979            cuda_error: 2,
980            message: "reservation limit".to_string(),
981        };
982
983        // SAFETY: a null error pointer is the supported missing-native-error
984        // case, so the conversion has nothing to free.
985        let err = unsafe {
986            check_error_with_payload(
987                cugraph_error_code_t::CUGRAPH_ALLOC_ERROR,
988                std::ptr::null_mut(),
989                payload,
990                None,
991                "allocation_op",
992            )
993        }
994        .expect_err("allocation payload must produce an error");
995
996        assert_eq!(err.kind(), ErrorKind::OutOfMemory);
997        assert_eq!(err.status(), ErrorStatus::Temporary);
998        assert_eq!(err.allocation_source(), AllocationSource::RmmOutOfMemory);
999        assert_eq!(err.allocation_failure_source(), AllocationFailureSource::ReservationLimit);
1000        assert_eq!(err.allocation_domain_id(), 11);
1001        assert_eq!(err.allocation_member_id(), 17);
1002        assert_eq!(err.allocation_generation(), 23);
1003        assert_eq!(err.allocation_requested_bytes(), 4096);
1004        assert_eq!(err.allocation_alignment(), 256);
1005        assert_eq!(err.allocation_cuda_error(), 2);
1006        assert_eq!(err.message(), "reservation limit");
1007    }
1008
1009    #[test]
1010    fn test_with_operation_stacking() {
1011        let err = Error::new(ErrorKind::InvalidInput, "inner_op").with_message("bad data").with_operation("outer_op");
1012
1013        assert_eq!(err.operation(), "outer_op");
1014        let display = err.to_string();
1015        assert!(display.contains("at outer_op"));
1016        assert!(display.contains("called: inner_op"));
1017    }
1018
1019    #[test]
1020    fn test_check_error_success() {
1021        // SAFETY: Passing a success code with a null error pointer matches the C API contract;
1022        // `check_error` will not dereference the pointer on success.
1023        unsafe {
1024            let result = check_host_error(cugraph_error_code_t::CUGRAPH_SUCCESS, std::ptr::null_mut(), "test");
1025            assert!(result.is_ok());
1026        }
1027    }
1028
1029    #[test]
1030    fn test_require_non_null_rejects_null_pointers() {
1031        let err = require_non_null::<u8>(std::ptr::null_mut(), "test_op", "result_ptr").expect_err("null must fail");
1032        assert_eq!(err.kind(), ErrorKind::Unexpected);
1033        assert_eq!(err.operation(), "test_op");
1034        assert!(err.message().contains("result_ptr"));
1035    }
1036
1037    #[test]
1038    fn test_check_error_failure_null_ptr() {
1039        // SAFETY: `check_error` accepts a null error pointer for failure paths and degrades to a
1040        // synthesized Rust error without dereferencing the pointer.
1041        unsafe {
1042            let result = check_host_error(cugraph_error_code_t::CUGRAPH_INVALID_INPUT, std::ptr::null_mut(), "test_op");
1043            assert!(result.is_err());
1044            let err = result.unwrap_err();
1045            assert_eq!(err.kind(), ErrorKind::InvalidInput);
1046            assert_eq!(err.operation(), "test_op");
1047            assert!(err.is_permanent());
1048        }
1049    }
1050
1051    #[test]
1052    fn test_check_error_failure_unknown_code_maps_to_unexpected() {
1053        // SAFETY: The test intentionally passes an unknown status code and a null error pointer to
1054        // verify `check_error`'s fallback mapping logic.
1055        unsafe {
1056            let result = check_host_error(cugraph_error_code_t(777), std::ptr::null_mut(), "test_op");
1057            assert!(result.is_err());
1058            let err = result.unwrap_err();
1059            assert_eq!(err.kind(), ErrorKind::Unexpected);
1060            assert_eq!(err.operation(), "test_op");
1061        }
1062    }
1063
1064    #[test]
1065    fn test_check_error_out_of_memory_is_temporary() {
1066        // SAFETY: The allocation failure code is exercised with a null error pointer to verify
1067        // Rust-side status mapping; `check_error` handles this without dereferencing the pointer.
1068        unsafe {
1069            let result = check_host_error(cugraph_error_code_t::CUGRAPH_ALLOC_ERROR, std::ptr::null_mut(), "alloc_op");
1070            assert!(result.is_err());
1071            let err = result.unwrap_err();
1072            assert_eq!(err.kind(), ErrorKind::OutOfMemory);
1073            assert!(err.is_temporary());
1074            assert_eq!(err.allocation_source(), AllocationSource::Unknown);
1075        }
1076    }
1077
1078    #[test]
1079    fn fatal_native_cuda_classification_quarantines_the_explicit_owner_device() {
1080        let scope = device_safety::DeviceSafetyTestScope::install([]);
1081        let device = device_safety::injected_device_id(7).expect("injected owner device");
1082
1083        // SAFETY: the null error pointer is permitted on failure and the typed
1084        // CUDA classification is injected directly into the Rust translation
1085        // layer, without crossing the production CUDA probe.
1086        let error = unsafe {
1087            check_error_with_payload(
1088                cugraph_error_code_t::CUGRAPH_UNKNOWN_ERROR,
1089                std::ptr::null_mut(),
1090                NativeErrorPayload::unknown(),
1091                Some((7, NativeCudaFailure::Fatal(700))),
1092                "test_fatal_cugraph_call",
1093            )
1094        }
1095        .expect_err("fatal CUDA classification must fail");
1096
1097        assert!(error.is_persistent());
1098        assert!(error.to_string().contains("cuda_failure_classification: fatal"));
1099        let snapshot = scope.health_snapshot(device);
1100        assert_eq!(snapshot.status(), DeviceHealthStatus::Unhealthy);
1101        assert_eq!(snapshot.fault_count(), 1);
1102        let first = snapshot.first_fault().expect("fatal fault");
1103        assert_eq!(first.kind(), DeviceFaultKind::FatalDeviceOperation);
1104        assert_eq!(first.operation(), "test_fatal_cugraph_call");
1105        assert_eq!(first.native_status(), Some(DeviceFaultNativeStatus::Cuda(700)));
1106    }
1107}