Skip to main content

motion_core/
error.rs

1//! Unified engine failures with retained causes and frame diagnostics.
2
3use std::{error::Error as StdError, fmt, io};
4
5use crate::ValidationError;
6
7/// Owned source error that can cross rendering-worker boundaries.
8pub type BoxedError = Box<dyn StdError + Send + Sync + 'static>;
9
10/// Stable categories for programmatic handling across engine boundaries.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum ErrorKind {
14    /// Invalid scene properties or exhausted structural/raster allowances.
15    Validation,
16    /// Filesystem or other operating-system I/O failure.
17    Io,
18    /// Loading or decoding a named resource failed.
19    Asset,
20    /// SVG parsing failed.
21    Svg,
22    /// Formula compilation failed.
23    Formula,
24    /// Project parameters could not be decoded.
25    Parameters,
26    /// Timeline construction or timing failed.
27    Timeline,
28    /// A requested GPU could not be initialized.
29    GpuUnavailable,
30    /// GPU submission, readback or allocation failed without confirmed device loss.
31    Gpu,
32    /// An initialized GPU device was lost.
33    DeviceLost,
34    /// A scene callback panicked at a worker boundary.
35    ScenePanic,
36    /// Video or audio encoding failed.
37    Encoder,
38    /// Cancellation was observed before output commit.
39    Cancelled,
40    /// An extension or other subsystem failed.
41    Other,
42}
43
44/// Engine failure retaining its original source and optional frame context.
45///
46/// Lower-level geometry APIs still return ValidationError directly. Converting
47/// them into this type retains all property/path information. Synchronous scene
48/// callbacks still unwind; workers turn caught payloads into ScenePanic.
49///
50/// ```
51/// use motion_core::{Error, ErrorKind};
52/// let error = Error::from(std::io::Error::other("disk failure")).at_frame(12);
53/// assert_eq!(error.kind(), ErrorKind::Io);
54/// assert_eq!(error.frame(), Some(12));
55/// assert!(error.downcast_ref::<std::io::Error>().is_some());
56/// ```
57#[derive(Debug)]
58#[non_exhaustive]
59pub enum Error {
60    /// Ordered diagnostics retained while authoring an invalid value.
61    BuildErrors(Vec<std::sync::Arc<Error>>),
62    /// Detailed scene/property diagnostic.
63    Validation(ValidationError),
64    /// Original filesystem or operating-system error.
65    Io(io::Error),
66    /// Named resource failure with the original decoder/load error.
67    Asset {
68        /// Resource category, such as image, font or model.
69        kind: &'static str,
70        /// User-facing resource identifier or source path.
71        source_name: String,
72        /// Original failure.
73        cause: BoxedError,
74    },
75    /// SVG parser failure.
76    Svg(BoxedError),
77    /// Formula compiler diagnostics.
78    Formula(String),
79    /// Structured formula diagnostics retaining original source ranges.
80    FormulaDiagnostics(BoxedError),
81    /// Invalid serialized project parameters.
82    Parameters(BoxedError),
83    /// Timeline construction failure.
84    Timeline {
85        /// Timeline identifier.
86        timeline: String,
87        /// Violated timing contract.
88        message: String,
89    },
90    /// GPU setup failure; explicit GPU selection must not silently fall back.
91    GpuUnavailable(BoxedError),
92    /// GPU submission/readback failure without a confirmed device-loss callback.
93    Gpu(BoxedError),
94    /// Runtime GPU loss with its original driver error.
95    DeviceLost(BoxedError),
96    /// Caught scene panic. The message is retained for string payloads.
97    ScenePanic {
98        /// Registered scene or timeline name.
99        timeline: String,
100        /// Requested frame index.
101        frame: u64,
102        /// Panic payload text, or a non-string-payload marker.
103        message: String,
104    },
105    /// Native encoder failure with its original source.
106    Encoder(BoxedError),
107    /// Export was cancelled before destination commit.
108    Cancelled,
109    /// An otherwise classified error annotated with a frame index.
110    AtFrame {
111        /// Requested frame index.
112        frame: u64,
113        /// Original classified error.
114        cause: Box<Error>,
115    },
116    /// Extension error not covered by a more specific category.
117    Other(BoxedError),
118}
119
120impl Error {
121    /// All deferred authoring diagnostics, preserving their original categories.
122    pub fn build_errors(&self) -> &[std::sync::Arc<Error>] {
123        match self {
124            Self::BuildErrors(errors) => errors,
125            Self::AtFrame { cause, .. } => cause.build_errors(),
126            _ => &[],
127        }
128    }
129
130    /// Associates an original failure with a named resource load.
131    pub fn asset(
132        kind: &'static str,
133        source_name: impl Into<String>,
134        cause: impl StdError + Send + Sync + 'static,
135    ) -> Self {
136        Self::Asset {
137            kind,
138            source_name: source_name.into(),
139            cause: Box::new(cause),
140        }
141    }
142
143    /// Classifies an error independently of any frame annotation.
144    pub fn kind(&self) -> ErrorKind {
145        match self {
146            Self::BuildErrors(errors) => errors
147                .first()
148                .map_or(ErrorKind::Validation, |error| error.kind()),
149            Self::Validation(_) => ErrorKind::Validation,
150            Self::Io(_) => ErrorKind::Io,
151            Self::Asset { .. } => ErrorKind::Asset,
152            Self::Svg(_) => ErrorKind::Svg,
153            Self::Formula(_) | Self::FormulaDiagnostics(_) => ErrorKind::Formula,
154            Self::Parameters(_) => ErrorKind::Parameters,
155            Self::Timeline { .. } => ErrorKind::Timeline,
156            Self::GpuUnavailable(_) => ErrorKind::GpuUnavailable,
157            Self::Gpu(_) => ErrorKind::Gpu,
158            Self::DeviceLost(_) => ErrorKind::DeviceLost,
159            Self::ScenePanic { .. } => ErrorKind::ScenePanic,
160            Self::Encoder(_) => ErrorKind::Encoder,
161            Self::Cancelled => ErrorKind::Cancelled,
162            Self::AtFrame { cause, .. } => cause.kind(),
163            Self::Other(_) => ErrorKind::Other,
164        }
165    }
166
167    /// Frame of failure, if supplied by rendering or a worker boundary.
168    pub fn frame(&self) -> Option<u64> {
169        match self {
170            Self::Validation(error) => error.frame.map(u64::from),
171            Self::ScenePanic { frame, .. } | Self::AtFrame { frame, .. } => Some(*frame),
172            _ => None,
173        }
174    }
175
176    /// Attaches a frame without replacing a more specific existing frame.
177    /// Validation diagnostics keep their original concrete type and fields.
178    pub fn at_frame(mut self, frame: u64) -> Self {
179        if self.frame().is_some() {
180            return self;
181        }
182        if let Self::Validation(error) = &mut self
183            && let Ok(frame) = u32::try_from(frame)
184        {
185            error.frame = Some(frame);
186            return self;
187        }
188        Self::AtFrame {
189            frame,
190            cause: Box::new(self),
191        }
192    }
193
194    /// Finds an original concrete error through the complete source chain.
195    /// This preserves property-diagnostic queries without parsing display strings.
196    pub fn downcast_ref<T: StdError + 'static>(&self) -> Option<&T> {
197        let mut current: Option<&(dyn StdError + 'static)> = Some(self);
198        while let Some(error) = current {
199            if let Some(error) = error.downcast_ref::<T>() {
200                return Some(error);
201            }
202            current = error.source();
203        }
204        None
205    }
206
207    /// Classifies a caught scene panic while preserving its scene and frame.
208    pub fn scene_panic(
209        timeline: impl Into<String>,
210        frame: u64,
211        payload: Box<dyn std::any::Any + Send>,
212    ) -> Self {
213        let message = payload
214            .downcast_ref::<String>()
215            .cloned()
216            .or_else(|| {
217                payload
218                    .downcast_ref::<&str>()
219                    .map(|message| (*message).to_owned())
220            })
221            .unwrap_or_else(|| "non-string panic payload".to_owned());
222        Self::ScenePanic {
223            timeline: timeline.into(),
224            frame,
225            message,
226        }
227    }
228}
229
230impl fmt::Display for Error {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::BuildErrors(errors) => {
234                write!(formatter, "{} authoring error(s)", errors.len())?;
235                for error in errors {
236                    write!(formatter, "\n  {error}")?;
237                }
238                Ok(())
239            }
240            Self::Validation(error) => error.fmt(formatter),
241            Self::Io(error) => error.fmt(formatter),
242            Self::Asset {
243                kind,
244                source_name,
245                cause,
246            } => write!(
247                formatter,
248                "{kind} asset `{source_name}` failed to load: {cause}"
249            ),
250            Self::Svg(cause) => write!(formatter, "SVG parse error: {cause}"),
251            Self::Formula(message) => write!(formatter, "formula compile error: {message}"),
252            Self::FormulaDiagnostics(cause) => write!(formatter, "formula compile error: {cause}"),
253            Self::Parameters(cause) => write!(formatter, "invalid parameters: {cause}"),
254            Self::Timeline { timeline, message } => {
255                write!(formatter, "timeline `{timeline}`: {message}")
256            }
257            Self::GpuUnavailable(cause) => write!(formatter, "GPU unavailable: {cause}"),
258            Self::Gpu(cause) => write!(formatter, "GPU error: {cause}"),
259            Self::DeviceLost(cause) => write!(formatter, "GPU device lost: {cause}"),
260            Self::ScenePanic {
261                timeline,
262                frame,
263                message,
264            } => write!(
265                formatter,
266                "scene panicked at frame {frame} of `{timeline}`: {message}"
267            ),
268            Self::Encoder(cause) => write!(formatter, "encoder error: {cause}"),
269            Self::Cancelled => formatter.write_str("export cancelled"),
270            Self::AtFrame { frame, cause } => write!(formatter, "frame {frame}: {cause}"),
271            Self::Other(cause) => cause.fmt(formatter),
272        }
273    }
274}
275
276impl StdError for Error {
277    fn source(&self) -> Option<&(dyn StdError + 'static)> {
278        match self {
279            Self::Validation(error) => Some(error),
280            Self::Io(error) => Some(error),
281            Self::Asset { cause, .. }
282            | Self::Svg(cause)
283            | Self::FormulaDiagnostics(cause)
284            | Self::Parameters(cause)
285            | Self::GpuUnavailable(cause)
286            | Self::Gpu(cause)
287            | Self::DeviceLost(cause)
288            | Self::Encoder(cause)
289            | Self::Other(cause) => Some(cause.as_ref()),
290            Self::AtFrame { cause, .. } => Some(cause.as_ref()),
291            _ => None,
292        }
293    }
294}
295
296impl From<ValidationError> for Error {
297    fn from(error: ValidationError) -> Self {
298        if error.field == "formula" {
299            Self::FormulaDiagnostics(Box::new(error))
300        } else {
301            Self::Validation(error)
302        }
303    }
304}
305
306impl From<io::Error> for Error {
307    fn from(error: io::Error) -> Self {
308        Self::Io(error)
309    }
310}
311
312impl From<BoxedError> for Error {
313    fn from(error: BoxedError) -> Self {
314        Self::Other(error)
315    }
316}
317
318impl From<std::collections::TryReserveError> for Error {
319    fn from(error: std::collections::TryReserveError) -> Self {
320        Self::Io(io::Error::new(io::ErrorKind::OutOfMemory, error))
321    }
322}
323
324impl From<std::env::VarError> for Error {
325    fn from(error: std::env::VarError) -> Self {
326        Self::Parameters(Box::new(error))
327    }
328}
329
330impl From<std::str::Utf8Error> for Error {
331    fn from(error: std::str::Utf8Error) -> Self {
332        Self::Other(Box::new(error))
333    }
334}
335
336impl From<std::sync::mpsc::RecvError> for Error {
337    fn from(error: std::sync::mpsc::RecvError) -> Self {
338        Self::Gpu(Box::new(error))
339    }
340}
341
342impl From<std::array::TryFromSliceError> for Error {
343    fn from(error: std::array::TryFromSliceError) -> Self {
344        Self::Gpu(Box::new(error))
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn classification_frames_and_sources_survive_annotation() {
354        fn send_sync<T: Send + Sync>() {}
355        send_sync::<Error>();
356        let original = io::Error::new(io::ErrorKind::PermissionDenied, "destination denied");
357        let error = Error::Encoder(Box::new(original)).at_frame(7).at_frame(9);
358        assert_eq!(error.kind(), ErrorKind::Encoder);
359        assert_eq!(error.frame(), Some(7));
360        assert_eq!(
361            error.downcast_ref::<io::Error>().unwrap().kind(),
362            io::ErrorKind::PermissionDenied
363        );
364        assert!(
365            error
366                .to_string()
367                .contains("frame 7: encoder error: destination denied")
368        );
369        let original = ValidationError::invalid("nodes[2]", "opacity", "invalid opacity");
370        let error = Error::from(original).at_frame(11);
371        let diagnostic = error.downcast_ref::<ValidationError>().unwrap();
372        assert_eq!(diagnostic.frame, Some(11));
373        assert_eq!(diagnostic.path, "nodes[2]");
374        assert_eq!(error.frame(), Some(11));
375        assert_eq!(error.kind(), ErrorKind::Validation);
376    }
377
378    #[test]
379    fn panic_payloads_keep_scene_and_frame() {
380        for payload in [
381            Box::new("bad scene") as Box<dyn std::any::Any + Send>,
382            Box::new("bad scene".to_owned()),
383        ] {
384            let error = Error::scene_panic("intro", 23, payload);
385            assert_eq!(error.kind(), ErrorKind::ScenePanic);
386            assert_eq!(error.frame(), Some(23));
387            assert!(error.to_string().contains("intro"));
388            assert!(error.to_string().contains("bad scene"));
389        }
390        assert!(
391            Error::scene_panic("intro", 1, Box::new(42_u32))
392                .to_string()
393                .contains("non-string")
394        );
395        assert_eq!(Error::Cancelled.kind(), ErrorKind::Cancelled);
396    }
397}