Skip to main content

j2k_metal/
error.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3mod native_source;
4
5use j2k::J2kError;
6#[cfg(target_os = "macos")]
7use j2k::{BackendError, BackendErrorKind};
8use j2k_core::{
9    adapter_error_is_buffer_error, adapter_error_is_not_implemented, adapter_error_is_truncated,
10    adapter_error_is_unsupported, AdapterErrorKind, AdapterErrorParts, BackendRequest,
11    BatchInfrastructureError, BufferError, CodecError,
12};
13use j2k_metal_support::MetalSupportError;
14#[cfg(any(test, target_os = "macos"))]
15use j2k_native::{DecodeError as NativeDecodeError, EncodeError as NativeEncodeError};
16
17pub use native_source::NativeBackendError;
18
19#[derive(Debug, thiserror::Error)]
20/// Errors returned by the Metal J2K backend.
21pub enum Error {
22    /// Error returned by the CPU or native J2K decoder.
23    #[error(transparent)]
24    Decode(#[from] J2kError),
25    /// Native decoder failure produced while preparing Metal-resident work.
26    #[error("{context}: {source}")]
27    NativeDecode {
28        /// Stable adapter operation context.
29        context: &'static str,
30        /// Concrete native decoder failure.
31        #[source]
32        source: NativeBackendError,
33    },
34    /// Native J2K encode helper failed after the Metal path produced inputs.
35    #[error("native J2K encode error during {operation}: {source}")]
36    NativeEncode {
37        /// Stable Metal adapter operation that crossed into native encoding.
38        operation: &'static str,
39        /// Concrete native encode failure.
40        #[source]
41        source: NativeBackendError,
42    },
43    /// Output buffer validation failed.
44    #[error(transparent)]
45    Buffer(#[from] BufferError),
46    /// CPU batch allocation, scheduling, or result collection failed independently of one tile.
47    #[error("{0}")]
48    BatchInfrastructure(
49        #[from]
50        #[source]
51        BatchInfrastructureError,
52    ),
53    /// The requested backend is unsupported by this crate.
54    #[error("backend request {request:?} is not supported by j2k-metal")]
55    UnsupportedBackend {
56        /// Backend requested by the caller.
57        request: BackendRequest,
58    },
59    /// A Metal-specific request is structurally unsupported.
60    #[error("unsupported J2K Metal request: {reason}")]
61    UnsupportedMetalRequest {
62        /// Static reason describing the rejected request.
63        reason: &'static str,
64    },
65    /// Metal is not available on the current host.
66    #[error("Metal is unavailable on this host")]
67    MetalUnavailable,
68    /// Metal runtime creation or device setup failed.
69    #[error("Metal runtime error: {message}")]
70    MetalRuntime {
71        /// Runtime error message.
72        message: String,
73    },
74    /// A shared Metal support operation failed with its typed source preserved.
75    #[error("{message}")]
76    MetalSupport {
77        /// Existing adapter diagnostic, including its operation context.
78        message: String,
79        /// Typed shared Metal support failure.
80        #[source]
81        source: MetalSupportError,
82    },
83    /// Prepared-plan cache storage could not reserve required host memory.
84    #[error("Metal kernel error: {context}: allocation failed: {source}")]
85    PreparedPlanCacheAllocation {
86        /// Cache operation that failed.
87        context: &'static str,
88        /// Original host reservation failure.
89        #[source]
90        source: std::collections::TryReserveError,
91    },
92    /// Prepared-plan cache bookkeeping violated an internal invariant.
93    #[error("Metal kernel error: {context}: cache invariant failed: {reason}")]
94    PreparedPlanCacheInvariant {
95        /// Cache operation that failed.
96        context: &'static str,
97        /// Static invariant diagnostic from the cache owner.
98        reason: &'static str,
99    },
100    /// Metal kernel launch, validation, or completion failed.
101    #[error("Metal kernel error: {message}")]
102    MetalKernel {
103        /// Kernel error message.
104        message: String,
105    },
106    /// Metal kernel failure with structured retry classification.
107    #[error("Metal kernel error: {message}")]
108    MetalKernelRetryable {
109        /// Kernel error message.
110        message: String,
111        /// Retry class assigned at the error construction site.
112        retry_class: MetalKernelRetryClass,
113    },
114    /// Metal direct decode path could not handle a request and should fall back.
115    #[error("Metal kernel error: {message}")]
116    MetalDirectFallback {
117        /// User-visible fallback message.
118        message: String,
119        /// Structured fallback reason assigned at the error construction site.
120        reason: MetalDirectFallbackReason,
121    },
122    /// Shared Metal backend state was poisoned by a prior panic.
123    #[error("Metal state `{state}` is poisoned")]
124    MetalStatePoisoned {
125        /// Name of the poisoned state.
126        state: &'static str,
127    },
128    /// Internal Metal state contradicted its checked ownership/accounting ledger.
129    #[error("Metal state `{state}` invariant failed: {reason}")]
130    MetalStateInvariant {
131        /// Name of the affected state owner.
132        state: &'static str,
133        /// Static invariant that was violated.
134        reason: &'static str,
135    },
136}
137
138/// Structured fallback class for Metal direct decode routing.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum MetalDirectFallbackReason {
142    /// Native direct-plan construction rejected the codestream or image shape.
143    UnsupportedPlan,
144    /// A prepared direct plan cannot be executed by the Metal runtime path.
145    UnsupportedRuntimeInput,
146}
147
148/// Conservative retry class for Metal kernel failures.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum MetalKernelRetryClass {
152    /// Retry a resident classic J2K batch with a conservative capacity path.
153    ResidentClassicBatch,
154    /// Retry a resident HTJ2K batch with a conservative capacity path.
155    ResidentHtBatch,
156    /// Retry either resident classic or HTJ2K batches.
157    ResidentClassicOrHtBatch,
158}
159
160impl MetalKernelRetryClass {
161    #[cfg(target_os = "macos")]
162    fn applies_to(self, requested: Self) -> bool {
163        self == requested
164            || matches!(
165                (self, requested),
166                (
167                    Self::ResidentClassicOrHtBatch,
168                    Self::ResidentClassicBatch | Self::ResidentHtBatch
169                )
170            )
171    }
172}
173
174impl Error {
175    /// Whether this failure invalidates retained Metal session state or an
176    /// operation-wide ownership boundary.
177    #[doc(hidden)]
178    #[must_use]
179    pub fn session_is_unusable(&self) -> bool {
180        matches!(
181            self,
182            Self::BatchInfrastructure(_)
183                | Self::PreparedPlanCacheAllocation { .. }
184                | Self::PreparedPlanCacheInvariant { .. }
185                | Self::MetalUnavailable
186                | Self::MetalRuntime { .. }
187                | Self::MetalStatePoisoned { .. }
188                | Self::MetalStateInvariant { .. }
189        )
190    }
191
192    #[cfg(target_os = "macos")]
193    pub(crate) fn is_conservative_retry_candidate(&self, requested: MetalKernelRetryClass) -> bool {
194        match self {
195            Self::MetalKernelRetryable { retry_class, .. } => retry_class.applies_to(requested),
196            _ => false,
197        }
198    }
199
200    #[cfg(target_os = "macos")]
201    pub(crate) fn is_direct_fallback(&self) -> bool {
202        matches!(self, Self::MetalDirectFallback { .. })
203    }
204}
205
206#[doc(hidden)]
207impl AdapterErrorParts for Error {
208    fn source_codec_error(&self) -> Option<&dyn CodecError> {
209        match self {
210            Self::Decode(inner) => Some(inner),
211            _ => None,
212        }
213    }
214
215    fn adapter_error_kind(&self) -> AdapterErrorKind {
216        match self {
217            Self::Buffer(_) => AdapterErrorKind::Buffer,
218            Self::UnsupportedBackend { .. }
219            | Self::UnsupportedMetalRequest { .. }
220            | Self::MetalUnavailable
221            | Self::MetalDirectFallback { .. } => AdapterErrorKind::Unsupported,
222            Self::Decode(_)
223            | Self::NativeDecode { .. }
224            | Self::NativeEncode { .. }
225            | Self::BatchInfrastructure(_)
226            | Self::MetalRuntime { .. }
227            | Self::MetalSupport { .. }
228            | Self::PreparedPlanCacheAllocation { .. }
229            | Self::PreparedPlanCacheInvariant { .. }
230            | Self::MetalKernel { .. }
231            | Self::MetalKernelRetryable { .. }
232            | Self::MetalStatePoisoned { .. }
233            | Self::MetalStateInvariant { .. } => AdapterErrorKind::Other,
234        }
235    }
236}
237
238#[doc(hidden)]
239impl CodecError for Error {
240    fn is_truncated(&self) -> bool {
241        matches!(
242            self,
243            Self::NativeDecode { source, .. } if source.is_decode_truncated()
244        ) || adapter_error_is_truncated(self)
245    }
246
247    fn is_not_implemented(&self) -> bool {
248        adapter_error_is_not_implemented(self)
249    }
250
251    fn is_unsupported(&self) -> bool {
252        matches!(
253            self,
254            Self::NativeDecode { source, .. } if source.is_unsupported()
255        ) || matches!(
256            self,
257            Self::NativeEncode { source, .. } if source.is_unsupported()
258        ) || adapter_error_is_unsupported(self)
259    }
260
261    fn is_buffer_error(&self) -> bool {
262        adapter_error_is_buffer_error(self)
263    }
264}
265
266#[cfg(any(test, target_os = "macos"))]
267pub(crate) fn native_decode_error(error: NativeDecodeError) -> Error {
268    Error::NativeDecode {
269        context: "native JPEG 2000 backend failed",
270        source: NativeBackendError::decode(error),
271    }
272}
273
274#[cfg(any(test, target_os = "macos"))]
275pub(crate) fn native_encode_error(operation: &'static str, source: NativeEncodeError) -> Error {
276    Error::NativeEncode {
277        operation,
278        source: NativeBackendError::encode(source),
279    }
280}
281
282#[cfg(any(test, target_os = "macos"))]
283pub(crate) fn metal_kernel_support_error(
284    message: impl Into<String>,
285    source: MetalSupportError,
286) -> Error {
287    Error::MetalSupport {
288        message: format!("Metal kernel error: {}", message.into()),
289        source,
290    }
291}
292
293#[cfg(any(test, target_os = "macos"))]
294pub(crate) fn metal_runtime_support_error(source: &MetalSupportError) -> Error {
295    if source.is_unavailable() {
296        Error::MetalUnavailable
297    } else {
298        Error::MetalSupport {
299            message: format!("Metal runtime error: {source}"),
300            source: source.clone(),
301        }
302    }
303}
304
305#[cfg(target_os = "macos")]
306pub(crate) fn adapter_backend_error(message: impl Into<String>) -> J2kError {
307    J2kError::Backend(BackendError::new(BackendErrorKind::Other, message))
308}
309
310#[cfg(test)]
311mod tests {
312    use j2k_core::CodecError;
313    use j2k_metal_support::MetalSupportError;
314    use j2k_native::{
315        DecodeError as NativeDecodeError, DecodingError as NativeDecodingError,
316        DirectPlanUnsupportedReason as NativeDirectPlanUnsupportedReason,
317        EncodeError as NativeEncodeError,
318    };
319
320    use super::{
321        metal_kernel_support_error, metal_runtime_support_error, native_decode_error,
322        native_encode_error, Error, NativeBackendError,
323    };
324
325    #[test]
326    fn native_decode_unsupported_error_keeps_codec_classification() {
327        let error = native_decode_error(NativeDecodeError::Decoding(
328            NativeDecodingError::UnsupportedFeature("test feature"),
329        ));
330
331        assert!(error.is_unsupported());
332        assert!(!error.is_truncated());
333        assert!(!error.is_not_implemented());
334    }
335
336    #[test]
337    fn native_encode_crossing_preserves_operation_and_concrete_source() {
338        let error = native_encode_error(
339            "classic Tier-1 token pack",
340            NativeEncodeError::ArithmeticOverflow {
341                what: "test token length",
342            },
343        );
344
345        assert!(error.to_string().contains("classic Tier-1 token pack"));
346        assert!(matches!(
347            &error,
348            Error::NativeEncode { operation, source }
349                if *operation == "classic Tier-1 token pack"
350                    && source == &NativeBackendError::encode(
351                        NativeEncodeError::ArithmeticOverflow {
352                            what: "test token length",
353                        }
354                    )
355        ));
356        let opaque = std::error::Error::source(&error).expect("opaque adapter source");
357        assert!(opaque.downcast_ref::<NativeBackendError>().is_some());
358        let concrete = opaque.source().expect("concrete native encode source");
359        assert!(matches!(
360            concrete.downcast_ref::<NativeEncodeError>(),
361            Some(NativeEncodeError::ArithmeticOverflow {
362                what: "test token length"
363            })
364        ));
365    }
366
367    #[test]
368    fn native_decode_direct_plan_error_keeps_codec_classification() {
369        let error = native_decode_error(NativeDecodeError::Decoding(
370            NativeDecodingError::DirectPlanUnsupported(
371                NativeDirectPlanUnsupportedReason::ColorSingleTileCodestream,
372            ),
373        ));
374
375        assert!(error.is_unsupported());
376        assert!(!error.is_truncated());
377        assert!(!error.is_not_implemented());
378    }
379
380    #[test]
381    fn native_decode_unexpected_eof_keeps_codec_classification() {
382        let error = native_decode_error(NativeDecodeError::Decoding(
383            NativeDecodingError::UnexpectedEof,
384        ));
385
386        assert!(error.is_truncated());
387        assert!(!error.is_unsupported());
388    }
389
390    #[test]
391    fn native_decode_resource_errors_preserve_typed_sources() {
392        let sources = [
393            NativeDecodeError::AllocationTooLarge {
394                what: "Metal decode fixture",
395                requested: 9,
396                cap: 8,
397            },
398            NativeDecodeError::HostAllocationFailed {
399                what: "Metal decode fixture",
400                bytes: 7,
401            },
402        ];
403
404        for source in sources {
405            let error = native_decode_error(source);
406            assert!(matches!(
407                &error,
408                Error::NativeDecode {
409                    context: "native JPEG 2000 backend failed",
410                    source: stored,
411                } if stored == &NativeBackendError::decode(source)
412            ));
413            let opaque = core::error::Error::source(&error).expect("opaque adapter source");
414            assert!(opaque.downcast_ref::<NativeBackendError>().is_some());
415            let concrete = opaque.source().expect("concrete native decode source");
416            assert_eq!(concrete.downcast_ref::<NativeDecodeError>(), Some(&source));
417            assert!(!error.is_buffer_error());
418            assert!(!error.is_unsupported());
419        }
420    }
421
422    #[test]
423    fn metal_support_error_keeps_display_source_and_other_classification() {
424        let source = MetalSupportError::BufferAlignment {
425            offset_bytes: 1,
426            align: 4,
427        };
428        let error = metal_kernel_support_error(
429            format!("J2K Metal status readback buffer access invalid: {source}"),
430            source.clone(),
431        );
432
433        assert_eq!(
434            error.to_string(),
435            "Metal kernel error: J2K Metal status readback buffer access invalid: Metal buffer range offset 1 is not aligned to 4 bytes"
436        );
437        assert!(matches!(
438            &error,
439            Error::MetalSupport { source: stored, .. } if stored == &source
440        ));
441        let chained = std::error::Error::source(&error).expect("typed Metal support source");
442        assert!(chained.downcast_ref::<MetalSupportError>().is_some());
443        assert!(!error.is_unsupported());
444        assert!(!error.is_buffer_error());
445    }
446
447    #[test]
448    fn runtime_unavailability_keeps_existing_unsupported_route() {
449        let error = metal_runtime_support_error(&MetalSupportError::MetalUnavailable);
450
451        assert!(matches!(error, Error::MetalUnavailable));
452        assert!(error.is_unsupported());
453    }
454
455    #[test]
456    fn persistent_session_classification_is_owned_by_metal_error() {
457        assert!(Error::MetalUnavailable.session_is_unusable());
458        assert!(Error::MetalRuntime {
459            message: "test runtime failure".to_string(),
460        }
461        .session_is_unusable());
462        assert!(Error::MetalStateInvariant {
463            state: "test state",
464            reason: "test invariant",
465        }
466        .session_is_unusable());
467        assert!(!Error::UnsupportedMetalRequest {
468            reason: "test unsupported request",
469        }
470        .session_is_unusable());
471        assert!(!Error::MetalKernel {
472            message: "test group status".to_string(),
473        }
474        .session_is_unusable());
475    }
476}