Skip to main content

kithara_decode/
error.rs

1use std::{error::Error as StdError, io, io::ErrorKind, num::TryFromIntError};
2
3use kithara_bufpool::BudgetExhausted;
4use kithara_stream::{AudioCodec, ContainerFormat, PendingReason, VariantChangeError};
5use thiserror::Error;
6
7/// Errors that can occur during audio decoding.
8///
9/// This error type is backend-agnostic, wrapping decoder-specific errors
10/// in the `Backend` variant.
11#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum DecodeError {
14    #[error("IO error: {0}")]
15    Io(io::Error),
16
17    #[error("Unsupported codec: {0:?}")]
18    UnsupportedCodec(AudioCodec),
19
20    #[error("Unsupported container: {0:?}")]
21    UnsupportedContainer(ContainerFormat),
22
23    #[error("Invalid data: {0}")]
24    InvalidData(String),
25
26    #[error("Seek failed: {0}")]
27    SeekFailed(String),
28
29    /// The seek target is invalid for this stream — past EOF, beyond the
30    /// indexed sample range, or otherwise out of the addressable space.
31    ///
32    /// Distinct from [`SeekFailed`](Self::SeekFailed) because there is no
33    /// recovery action that helps: the duration/length come from the
34    /// stream itself, not from decoder state, so a freshly built decoder
35    /// rejects the same target with the same answer. Pipeline must
36    /// surface this to the caller (fail the seek) rather than retry.
37    #[error("Seek target out of range: {0}")]
38    SeekOutOfRange(String),
39
40    #[error("Probe failed: could not detect codec")]
41    ProbeFailed,
42
43    /// The caller selected a backend that is not compiled in for the
44    /// current target/feature combination (e.g. `DecoderBackend::Apple`
45    /// on Linux, or `DecoderBackend::Symphonia` without the `symphonia`
46    /// feature). Distinct from [`UnsupportedCodec`](Self::UnsupportedCodec)
47    /// (codec/container the backend cannot handle): `BackendUnavailable`
48    /// means the backend itself is absent from the binary.
49    #[error("Backend unavailable: {backend}")]
50    BackendUnavailable { backend: &'static str },
51
52    /// A seek interrupted the decode operation. Not a real error —
53    /// the caller should check for pending seeks and retry.
54    #[error("Interrupted by seek")]
55    Interrupted,
56
57    #[error("Decoder error: {0}")]
58    Backend(#[source] Box<dyn StdError + Send + Sync>),
59}
60
61fn is_seek_pending_io(err: &io::Error) -> bool {
62    err.kind() == ErrorKind::Interrupted
63        || err
64            .get_ref()
65            .and_then(|src| src.downcast_ref::<PendingReason>())
66            .is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
67}
68
69fn is_variant_change_io(err: &io::Error) -> bool {
70    err.get_ref()
71        .and_then(|source| source.downcast_ref::<VariantChangeError>())
72        .is_some()
73}
74
75fn walk_error_chain<I, L>(err: &(dyn StdError + 'static), check_io: &I, check_leaf: &L) -> bool
76where
77    I: Fn(&io::Error) -> bool,
78    L: Fn(&(dyn StdError + 'static)) -> bool,
79{
80    let io_hit = err.downcast_ref::<io::Error>().map(check_io);
81    #[cfg(feature = "symphonia")]
82    let symphonia_hit = crate::symphonia::echain::inspect(err, check_io, check_leaf);
83    #[cfg(not(feature = "symphonia"))]
84    let symphonia_hit: Option<bool> = None;
85    let leaf_hit = check_leaf(err);
86    match (io_hit, symphonia_hit, leaf_hit) {
87        (Some(hit), _, _) | (None, Some(hit), _) => hit,
88        (None, None, true) => true,
89        (None, None, false) => err
90            .source()
91            .is_some_and(|source| walk_error_chain(source, check_io, check_leaf)),
92    }
93}
94
95fn error_chain_is_interrupted(err: &(dyn StdError + 'static)) -> bool {
96    walk_error_chain(err, &is_seek_pending_io, &|leaf| {
97        leaf.downcast_ref::<PendingReason>()
98            .is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
99    })
100}
101
102fn error_chain_is_variant_change(err: &(dyn StdError + 'static)) -> bool {
103    walk_error_chain(err, &is_variant_change_io, &|leaf| {
104        leaf.downcast_ref::<VariantChangeError>().is_some()
105    })
106}
107
108/// Single-discriminant classification of a [`DecodeError`].
109///
110/// Walking the source chain via separate per-class boolean predicates
111/// in sequence forces the chain to be traversed twice on the failure
112/// path. [`DecodeError::classify`] runs the walk once and returns this
113/// tag so callers can `match` instead of cascading boolean predicates.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum ErrorClass {
117    /// Interrupted by seek / cooperative pending — caller should retry.
118    Interrupted,
119    /// Cross-variant boundary — caller must recreate the decoder.
120    VariantChange,
121    /// Anything else — caller surfaces as terminal failure.
122    Other,
123}
124
125impl DecodeError {
126    /// Wrap any `StdError + Send + Sync` payload as a [`DecodeError::Backend`].
127    /// Avoids 30+ repeats of `.map_err(|e| DecodeError::Backend(Box::new(e)))`
128    /// across the apple / android / symphonia codec layers.
129    #[must_use]
130    pub fn backend<E>(err: E) -> Self
131    where
132        E: Into<Box<dyn StdError + Send + Sync>>,
133    {
134        Self::Backend(err.into())
135    }
136
137    /// Convenience constructor for "we have a description string, not
138    /// a typed source error" — typically `format!(...)` payloads for
139    /// FFI status codes (Apple `OSStatus`, Android `media_status_t`).
140    #[must_use]
141    pub fn backend_msg<S>(msg: S) -> Self
142    where
143        S: Into<String>,
144    {
145        Self::Backend(Box::new(io::Error::other(msg.into())))
146    }
147
148    /// Tag the error in one source-chain pass so hot decode loops can
149    /// replace per-class boolean predicate ladders with a single
150    /// `match` over the discriminant.
151    #[must_use]
152    #[inline]
153    // ast-grep-ignore: idioms.match-self-conversion
154    pub fn classify(&self) -> ErrorClass {
155        match self {
156            Self::Interrupted => ErrorClass::Interrupted,
157            Self::Io(err) => {
158                if is_variant_change_io(err) {
159                    ErrorClass::VariantChange
160                } else if is_seek_pending_io(err) {
161                    ErrorClass::Interrupted
162                } else {
163                    ErrorClass::Other
164                }
165            }
166            Self::Backend(err) => {
167                let leaf = err.as_ref();
168                if error_chain_is_variant_change(leaf) {
169                    ErrorClass::VariantChange
170                } else if error_chain_is_interrupted(leaf) {
171                    ErrorClass::Interrupted
172                } else {
173                    ErrorClass::Other
174                }
175            }
176            _ => ErrorClass::Other,
177        }
178    }
179
180    /// Returns `true` if the error is an [`Interrupted`](Self::Interrupted) variant.
181    #[must_use]
182    pub fn is_interrupted(&self) -> bool {
183        matches!(self.classify(), ErrorClass::Interrupted)
184    }
185}
186
187impl From<io::Error> for DecodeError {
188    fn from(err: io::Error) -> Self {
189        if err.kind() == ErrorKind::Interrupted {
190            Self::Interrupted
191        } else {
192            Self::Io(err)
193        }
194    }
195}
196
197impl From<TryFromIntError> for DecodeError {
198    fn from(err: TryFromIntError) -> Self {
199        Self::backend(err)
200    }
201}
202
203impl From<BudgetExhausted> for DecodeError {
204    fn from(err: BudgetExhausted) -> Self {
205        Self::backend(err)
206    }
207}
208
209/// Result type for decode operations.
210pub type DecodeResult<T> = Result<T, DecodeError>;
211
212#[cfg(test)]
213mod tests {
214    use std::io::{Error as IoError, ErrorKind};
215
216    use kithara_test_utils::kithara;
217
218    use super::*;
219
220    #[kithara::test]
221    #[case::invalid_data(DecodeError::InvalidData("bad frame".into()), "Invalid data: bad frame")]
222    #[case::seek_failed(DecodeError::SeekFailed("timestamp out of range".into()), "Seek failed: timestamp out of range")]
223    #[case::probe_failed(DecodeError::ProbeFailed, "Probe failed: could not detect codec")]
224    #[case::unsupported_codec(
225        DecodeError::UnsupportedCodec(AudioCodec::AacLc),
226        "Unsupported codec: AacLc"
227    )]
228    #[case::unsupported_container(
229        DecodeError::UnsupportedContainer(ContainerFormat::Fmp4),
230        "Unsupported container: Fmp4"
231    )]
232    fn test_error_display(#[case] error: DecodeError, #[case] expected: &str) {
233        assert_eq!(error.to_string(), expected);
234    }
235
236    #[derive(Debug, Clone, Copy)]
237    enum ExpectedKind {
238        Io,
239        Interrupted,
240    }
241
242    #[kithara::test]
243    #[case::not_found_becomes_io(ErrorKind::NotFound, "file not found", ExpectedKind::Io)]
244    #[case::interrupted_becomes_interrupted(
245        ErrorKind::Interrupted,
246        "seek pending",
247        ExpectedKind::Interrupted
248    )]
249    fn test_decode_error_from_io(
250        #[case] kind: ErrorKind,
251        #[case] msg: &str,
252        #[case] expected: ExpectedKind,
253    ) {
254        let io_err = IoError::new(kind, msg);
255        let decode_err: DecodeError = io_err.into();
256        match expected {
257            ExpectedKind::Io => assert!(matches!(decode_err, DecodeError::Io(_))),
258            ExpectedKind::Interrupted => assert!(matches!(decode_err, DecodeError::Interrupted)),
259        }
260    }
261
262    #[kithara::test]
263    fn test_decode_error_backend_wraps_any_error() {
264        let err = DecodeError::backend(IoError::other("symphonia error"));
265        assert!(err.to_string().contains("Decoder error"));
266    }
267
268    #[kithara::test]
269    fn test_decode_error_backend_msg_wraps_a_display() {
270        let err = DecodeError::backend_msg(format!("oss status {}", 42));
271        assert!(err.to_string().contains("oss status 42"));
272    }
273
274    #[kithara::test]
275    fn test_decode_error_is_send_sync() {
276        fn assert_send_sync<T: Send + Sync>() {}
277        assert_send_sync::<DecodeError>();
278    }
279
280    #[kithara::test]
281    #[case::seek_pending_counts_as_interrupted(
282        DecodeError::backend(IoError::other(PendingReason::SeekPending)),
283        true
284    )]
285    #[case::other_io_is_not_interrupted(
286        DecodeError::backend(IoError::other("other backend error")),
287        false
288    )]
289    fn test_backend_is_interrupted(#[case] decode_err: DecodeError, #[case] expected: bool) {
290        assert_eq!(decode_err.is_interrupted(), expected);
291    }
292
293    #[kithara::test]
294    fn test_io_variant_change_is_detected() {
295        let decode_err = DecodeError::Io(IoError::other(VariantChangeError));
296        assert_eq!(decode_err.classify(), ErrorClass::VariantChange);
297        assert!(!decode_err.is_interrupted());
298    }
299}