Skip to main content

flexaudio_core/
error_context.rs

1//! Typed failure context and retained shutdown outcomes.
2use crate::types::MixLane;
3use crate::{Error, Result};
4use std::fmt;
5
6/// Operation that failed, independent of diagnostic strings.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum Operation {
10    /// Device inventory query.
11    Enumerate,
12    /// Capture startup.
13    Start,
14    /// Audio normalization.
15    Normalize,
16    /// Processing tail drain.
17    Flush,
18    /// Automatic capture reopen.
19    Reopen,
20    /// Restore the previous capture source.
21    Rollback,
22    /// Producer shutdown.
23    Stop,
24    /// Worker join.
25    Join,
26    /// Native audio routing.
27    Link,
28}
29impl fmt::Display for Operation {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        f.write_str(match self {
32            Self::Enumerate => "enumerate",
33            Self::Start => "start",
34            Self::Normalize => "normalize",
35            Self::Flush => "flush",
36            Self::Reopen => "reopen",
37            Self::Rollback => "rollback",
38            Self::Stop => "stop",
39            Self::Join => "join",
40            Self::Link => "link",
41        })
42    }
43}
44
45/// Native status; call labels are available only through explicit structured access.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum NativeStatus {
49    /// Windows HRESULT bit pattern.
50    HResult {
51        /// Library-owned call identifier.
52        call: &'static str,
53        /// Unsigned HRESULT bits.
54        bits: u32,
55    },
56    /// Apple OSStatus value.
57    OsStatus {
58        /// Library-owned call identifier.
59        call: &'static str,
60        /// Signed OSStatus.
61        value: i32,
62    },
63}
64
65/// Context attached without replacing the root failure kind.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct ErrorContext {
69    operation: Operation,
70    lane: Option<MixLane>,
71    native_status: Option<NativeStatus>,
72}
73impl ErrorContext {
74    /// Construct operation-only context.
75    pub fn new(operation: Operation) -> Self {
76        Self {
77            operation,
78            lane: None,
79            native_status: None,
80        }
81    }
82    /// Attach the failing Mix lane.
83    pub fn with_lane(mut self, lane: MixLane) -> Self {
84        self.lane = Some(lane);
85        self
86    }
87    /// Attach a structured native status.
88    pub fn with_native_status(mut self, status: NativeStatus) -> Self {
89        self.native_status = Some(status);
90        self
91    }
92    /// Failed operation.
93    pub fn operation(&self) -> Operation {
94        self.operation
95    }
96    /// Failing Mix lane, if any.
97    pub fn lane(&self) -> Option<MixLane> {
98        self.lane
99    }
100    /// Native status, if any.
101    pub fn native_status(&self) -> Option<NativeStatus> {
102        self.native_status
103    }
104}
105impl fmt::Display for ErrorContext {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "during {}", self.operation)?;
108        if let Some(lane) = self.lane {
109            f.write_str(match lane {
110                MixLane::Microphone => " (microphone)",
111                MixLane::SystemAudio => " (system audio)",
112            })?;
113        }
114        match self.native_status {
115            Some(NativeStatus::HResult { bits, .. }) => write!(f, " (HRESULT 0x{bits:08X})"),
116            Some(NativeStatus::OsStatus { value, .. }) => write!(f, " (OSStatus {value})"),
117            None => Ok(()),
118        }
119    }
120}
121
122/// A primary failure with at least one ordered related failure.
123#[derive(Debug, Clone, PartialEq, Eq)]
124#[non_exhaustive]
125pub struct ErrorGroup {
126    primary: Box<Error>,
127    first_secondary: Box<Error>,
128    remaining_secondary: Vec<Error>,
129}
130impl ErrorGroup {
131    /// Construct a nonempty group.
132    pub fn new(primary: Error, first_secondary: Error, remaining_secondary: Vec<Error>) -> Self {
133        Self {
134            primary: Box::new(primary),
135            first_secondary: Box::new(first_secondary),
136            remaining_secondary,
137        }
138    }
139    /// Primary failure.
140    pub fn primary(&self) -> &Error {
141        &self.primary
142    }
143    /// Related failures in observation order.
144    pub fn secondary(&self) -> impl Iterator<Item = &Error> {
145        std::iter::once(self.first_secondary.as_ref()).chain(self.remaining_secondary.iter())
146    }
147}
148
149/// Completed teardown; capture primary and cleanup causes remain distinct.
150#[derive(Debug, Clone, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct ShutdownReport {
153    primary: Option<Error>,
154    cleanup: Vec<Error>,
155}
156impl ShutdownReport {
157    /// Construct a completed report, including a clean empty outcome.
158    pub fn new(primary: Option<Error>, cleanup: Vec<Error>) -> Self {
159        Self { primary, cleanup }
160    }
161    /// First capture failure; cleanup-only failures do not become capture failures.
162    pub fn primary(&self) -> Option<&Error> {
163        self.primary.as_ref()
164    }
165    /// Cleanup failures in observation order.
166    pub fn cleanup(&self) -> &[Error] {
167        &self.cleanup
168    }
169    /// Checked capture outcome. Isolated VAD cleanup causes remain in the report but do not
170    /// fail stop; a capture or other cleanup failure still retains all related causes.
171    pub fn result(&self) -> Result<()> {
172        let first = match self.primary.as_ref() {
173            Some(primary) => primary,
174            None => match self
175                .cleanup
176                .iter()
177                .find(|error| error.whisper_code().is_none())
178            {
179                Some(error) => error,
180                None => return Ok(()),
181            },
182        };
183        let mut causes = std::iter::once(first.clone()).chain(
184            self.cleanup
185                .iter()
186                .filter(|error| !std::ptr::eq(*error, first))
187                .cloned(),
188        );
189        match (causes.next(), causes.next()) {
190            (None, _) => Ok(()),
191            (Some(error), None) => Err(error),
192            (Some(primary), Some(first)) => Err(Error::Multiple(ErrorGroup::new(
193                primary,
194                first,
195                causes.collect(),
196            ))),
197        }
198    }
199}
200
201#[cfg(test)]
202mod whisper_tests {
203    use super::*;
204    use crate::WhisperCode;
205
206    #[test]
207    fn whisper_only_shutdown_succeeds_and_retains_the_typed_cause() {
208        let cause = Error::WhisperVad {
209            code: WhisperCode::Inference,
210        }
211        .with_context(ErrorContext::new(Operation::Flush));
212        let report = ShutdownReport::new(None, vec![cause.clone()]);
213        assert!(report.result().is_ok());
214        assert!(report.primary().is_none());
215        assert_eq!(report.cleanup(), &[cause]);
216        assert_eq!(
217            report.cleanup()[0].whisper_code(),
218            Some(WhisperCode::Inference)
219        );
220        assert!(report.cleanup()[0]
221            .to_string()
222            .contains("model inference or reset could not complete"));
223        assert!(ShutdownReport::new(None, Vec::new()).result().is_ok());
224    }
225
226    #[test]
227    fn capture_shutdown_message_excludes_isolated_whisper_related_causes() {
228        let capture = Error::DeviceLost
229            .with_context(ErrorContext::new(Operation::Stop))
230            .with_context(ErrorContext::new(Operation::Stop));
231        for operation in [Operation::Normalize, Operation::Flush] {
232            let whisper = Error::WhisperVad {
233                code: WhisperCode::Inference,
234            }
235            .with_context(ErrorContext::new(operation));
236            let report = ShutdownReport::new(Some(capture.clone()), vec![whisper.clone()]);
237            let error = report
238                .result()
239                .expect_err("capture loss must still fail stop");
240            assert_eq!(error.to_string(), "device lost during stop during stop");
241            assert_eq!(error.root(), &Error::DeviceLost);
242            assert_eq!(report.cleanup(), std::slice::from_ref(&whisper));
243            match error {
244                Error::Multiple(group) => {
245                    assert_eq!(group.secondary().collect::<Vec<_>>(), [&whisper]);
246                    assert_eq!(
247                        group.secondary().next().and_then(Error::whisper_code),
248                        Some(WhisperCode::Inference)
249                    );
250                }
251                _ => panic!("the rejecting error must retain typed related failures"),
252            }
253        }
254    }
255
256    #[test]
257    fn capture_shutdown_message_retains_non_whisper_related_causes() {
258        let capture = Error::DeviceLost.with_context(ErrorContext::new(Operation::Stop));
259        let cleanup = Error::Backend("the backend could not finish its shutdown".into())
260            .with_context(ErrorContext::new(Operation::Join));
261        let whisper = Error::WhisperVad {
262            code: WhisperCode::Inference,
263        }
264        .with_context(ErrorContext::new(Operation::Flush));
265        let report = ShutdownReport::new(Some(capture), vec![whisper, cleanup]);
266        let error = report
267            .result()
268            .expect_err("capture loss must still fail stop");
269        assert_eq!(error.to_string(), "device lost during stop; related failure: backend error: the backend could not finish its shutdown during join");
270        assert!(matches!(error, Error::Multiple(group) if group.secondary().count() == 2));
271    }
272
273    #[test]
274    fn real_shutdown_failures_remain_primary_and_retain_whisper_related_causes() {
275        let whisper = Error::WhisperVad {
276            code: WhisperCode::Conversion,
277        };
278        let capture = Error::DeviceLost;
279        let report = ShutdownReport::new(Some(capture.clone()), vec![whisper.clone()]);
280        let error = report
281            .result()
282            .expect_err("capture loss must still fail stop");
283        assert_eq!(error.root(), &capture);
284        assert!(
285            matches!(error, Error::Multiple(ref group) if group.secondary().next() == Some(&whisper))
286        );
287
288        // A real cleanup failure is the rejecting cause, even if VAD failed first.
289        let cleanup = Error::Backend("the backend could not finish its shutdown".into());
290        let report = ShutdownReport::new(None, vec![whisper.clone(), cleanup.clone()]);
291        let error = report
292            .result()
293            .expect_err("backend teardown failure must still fail stop");
294        assert_eq!(error.root(), &cleanup);
295        assert!(
296            matches!(error, Error::Multiple(ref group) if group.secondary().next() == Some(&whisper))
297        );
298    }
299}