Skip to main content

camel_component_wasm/
error.rs

1//! WASM component error types.
2//!
3//! Provides structured error classification for WASM execution failures:
4//! - Timeout (epoch deadline expired)
5//! - Trap (guest panic, stack overflow, unreachable, etc.)
6//! - Out of memory (linear memory exceeded limit)
7//! - Unhealthy (consecutive failures, unable to re-instantiate)
8//!
9//! All variants carry context (plugin name, timeout value, etc.) for debugging
10//! and error handler integration (dead letter channel, retry policies).
11
12use camel_api::CamelError;
13
14/// Classification of a WASM trap reason.
15///
16/// Used to provide structured error information to error handlers
17/// instead of a flat string message.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TrapReason {
20    /// Execution exceeded the configured timeout.
21    Timeout,
22    /// Linear memory exceeded the configured limit.
23    OutOfMemory,
24    /// `unreachable` instruction executed (guest panic).
25    Unreachable,
26    /// Call stack exceeded limit.
27    StackOverflow,
28    /// Other trap with description.
29    Other(String),
30}
31
32impl std::fmt::Display for TrapReason {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Timeout => write!(f, "execution timeout"),
36            Self::OutOfMemory => write!(f, "out of memory"),
37            Self::Unreachable => write!(f, "unreachable instruction"),
38            Self::StackOverflow => write!(f, "stack overflow"),
39            Self::Other(msg) => write!(f, "{msg}"),
40        }
41    }
42}
43
44/// Errors that can occur during WASM plugin execution.
45// TODO(WASM-005): WasmError may conflict with WIT-generated types in bindings.
46// If WIT adds an error resource named WasmError, rename this to CamelWasmError.
47#[derive(Debug, Clone, thiserror::Error)]
48pub enum WasmError {
49    #[error("WASM module not found: {0}")]
50    ModuleNotFound(String),
51
52    #[error("WASM compilation failed: {0}")]
53    CompilationFailed(String),
54
55    #[error("WASM instantiation failed: {0}")]
56    InstantiationFailed(String),
57
58    #[error("WASM guest panicked (trap): {0}")]
59    GuestPanic(String),
60
61    #[error("WASM invoke cancelled: {0}")]
62    Cancelled(String),
63
64    #[error("WASM type conversion failed: {0}")]
65    TypeConversion(String),
66
67    #[error("WASM I/O error: {0}")]
68    Io(String),
69
70    #[error("WASM configuration error: {0}")]
71    Config(String),
72
73    // ── Phase 4: Structured error variants ──────────────────────────────
74    #[error("WASM plugin '{plugin}' timed out after {timeout_secs}s")]
75    Timeout { plugin: String, timeout_secs: u64 },
76
77    #[error("WASM plugin '{plugin}' trapped: {reason}")]
78    Trap { plugin: String, reason: TrapReason },
79
80    #[error("WASM plugin '{plugin}' exceeded memory limit ({max_memory_bytes} bytes)")]
81    OutOfMemory {
82        plugin: String,
83        max_memory_bytes: u64,
84    },
85
86    #[error("WASM plugin '{plugin}' is unhealthy: {detail}")]
87    Unhealthy { plugin: String, detail: String },
88}
89
90impl WasmError {
91    /// Classify a wasmtime `Trap` into a `TrapReason`.
92    ///
93    /// Uses the trap type for well-known cases and falls back to
94    /// message matching for epoch-related traps.
95    ///
96    /// **Note:** `wasmtime::Trap` is `#[non_exhaustive]` — the `other` catch-all
97    /// arm is **required** and handles any future trap variants added by wasmtime.
98    pub fn classify_trap(trap: &wasmtime::Trap) -> TrapReason {
99        match trap {
100            wasmtime::Trap::StackOverflow => TrapReason::StackOverflow,
101            wasmtime::Trap::MemoryOutOfBounds => TrapReason::OutOfMemory,
102            wasmtime::Trap::UnreachableCodeReached => TrapReason::Unreachable,
103            wasmtime::Trap::Interrupt => TrapReason::Timeout,
104            // #[non_exhaustive] catch-all
105            other => {
106                let msg = other.to_string();
107                if msg.contains("epoch") {
108                    TrapReason::Timeout
109                } else {
110                    TrapReason::Other(msg)
111                }
112            }
113        }
114    }
115
116    /// Returns the plugin name associated with this error, if any.
117    pub fn plugin_name(&self) -> Option<&str> {
118        match self {
119            Self::Timeout { plugin, .. } => Some(plugin),
120            Self::Trap { plugin, .. } => Some(plugin),
121            Self::OutOfMemory { plugin, .. } => Some(plugin),
122            Self::Unhealthy { plugin, .. } => Some(plugin),
123            _ => None,
124        }
125    }
126}
127
128impl From<WasmError> for CamelError {
129    fn from(err: WasmError) -> Self {
130        match &err {
131            WasmError::GuestPanic(msg) => CamelError::ProcessorError(format!("wasm trap: {msg}")),
132            WasmError::Cancelled(msg) => {
133                CamelError::ProcessorError(format!("wasm invoke cancelled: {msg}"))
134            }
135            WasmError::TypeConversion(msg) => CamelError::TypeConversionFailed(msg.clone()),
136            WasmError::ModuleNotFound(msg) => CamelError::ComponentNotFound(msg.clone()),
137            WasmError::CompilationFailed(msg) => {
138                CamelError::Config(format!("wasm compilation failed: {msg}"))
139            }
140            WasmError::InstantiationFailed(msg) => {
141                CamelError::Config(format!("wasm instantiation failed: {msg}"))
142            }
143            WasmError::Io(msg) => CamelError::Io(msg.clone()),
144            WasmError::Config(msg) => CamelError::Config(msg.clone()),
145            // ── Phase 4 structured variants ──
146            WasmError::Timeout {
147                plugin,
148                timeout_secs,
149            } => CamelError::ProcessorError(format!(
150                "WASM plugin '{}' timed out after {}s",
151                plugin, timeout_secs
152            )),
153            WasmError::Trap { plugin, reason } => {
154                CamelError::ProcessorError(format!("wasm trap: plugin '{}': {}", plugin, reason))
155            }
156            WasmError::OutOfMemory {
157                plugin,
158                max_memory_bytes,
159            } => CamelError::ProcessorError(format!(
160                "WASM plugin '{}' exceeded memory limit ({} bytes)",
161                plugin, max_memory_bytes
162            )),
163            WasmError::Unhealthy { plugin, detail } => CamelError::ProcessorError(format!(
164                "WASM plugin '{}' is unhealthy: {}",
165                plugin, detail
166            )),
167        }
168    }
169}
170
171impl From<crate::bindings::camel::plugin::types::WasmError> for CamelError {
172    fn from(err: crate::bindings::camel::plugin::types::WasmError) -> Self {
173        map_plugin_error(err).into()
174    }
175}
176
177impl From<crate::bean_bindings::camel::plugin::types::WasmError> for CamelError {
178    fn from(err: crate::bean_bindings::camel::plugin::types::WasmError) -> Self {
179        map_bean_error(err).into()
180    }
181}
182
183impl From<crate::source_bindings::camel::plugin::types::WasmError> for CamelError {
184    fn from(err: crate::source_bindings::camel::plugin::types::WasmError) -> Self {
185        map_source_error(err).into()
186    }
187}
188
189/// Map a WIT bindings [`plugin::WasmError`] to the canonical crate-level [`WasmError`].
190///
191/// Both the `call_process` and `process_streaming_exchange` paths encounter
192/// the WIT-level error type after [`peel_concurrent`] strips the wasmtime
193/// error layers. This remaps the four WIT variants to the crate's own
194/// error variants, collapsing `Timeout` into `GuestPanic` (the guest timed
195/// out — not a host-level epoch deadline).
196pub fn map_plugin_error(wasm_err: crate::bindings::camel::plugin::types::WasmError) -> WasmError {
197    use crate::bindings::camel::plugin::types::WasmError as PluginWasmError;
198    match wasm_err {
199        PluginWasmError::ProcessorError(s) => WasmError::GuestPanic(s),
200        PluginWasmError::TypeConversion(s) => WasmError::TypeConversion(s),
201        PluginWasmError::Io(s) => WasmError::Io(s),
202        PluginWasmError::Timeout => WasmError::GuestPanic("guest timeout".into()),
203    }
204}
205
206/// Map a bean-world WIT `WasmError` to the canonical crate-level [`WasmError`].
207///
208/// Mirrors [`map_plugin_error`] for the bean binding namespace.
209pub fn map_bean_error(
210    wasm_err: crate::bean_bindings::camel::plugin::types::WasmError,
211) -> WasmError {
212    use crate::bean_bindings::camel::plugin::types::WasmError as BeanWasmError;
213    match wasm_err {
214        BeanWasmError::ProcessorError(s) => WasmError::GuestPanic(s),
215        BeanWasmError::TypeConversion(s) => WasmError::TypeConversion(s),
216        BeanWasmError::Io(s) => WasmError::Io(s),
217        BeanWasmError::Timeout => WasmError::GuestPanic("guest timeout".into()),
218    }
219}
220
221/// Map a source-world WIT `WasmError` to the canonical crate-level [`WasmError`].
222///
223/// Mirrors [`map_plugin_error`] for the source binding namespace. Needed so the
224/// submit-exchange drain (`drain_guest_stream`) can map the source world's
225/// terminal-future error into a `CamelError` via `E: Into<CamelError>`.
226pub fn map_source_error(
227    wasm_err: crate::source_bindings::camel::plugin::types::WasmError,
228) -> WasmError {
229    use crate::source_bindings::camel::plugin::types::WasmError as SourceWasmError;
230    match wasm_err {
231        SourceWasmError::ProcessorError(s) => WasmError::GuestPanic(s),
232        SourceWasmError::TypeConversion(s) => WasmError::TypeConversion(s),
233        SourceWasmError::Io(s) => WasmError::Io(s),
234        SourceWasmError::Timeout => WasmError::GuestPanic("guest timeout".into()),
235    }
236}
237
238/// `?` ergonomics for the bindgen trappable layer: when a generated
239/// `call_*(...)` returns `Result<_, wasmtime::Error>` (the trappable wrapper
240/// around a WIT `result<_, _>`), this impl lets `?` convert the trap to
241/// the canonical `WasmError::GuestPanic` (further classified later via
242/// `classify_error` if more precision is needed).
243impl From<wasmtime::Error> for WasmError {
244    fn from(err: wasmtime::Error) -> Self {
245        WasmError::GuestPanic(err.to_string())
246    }
247}
248
249/// Peel the layers of a `run_concurrent` result.
250///
251/// The bindgen-generated `async | store` shape with a WIT `result<_, _>`
252/// produces a 2-layer result from `Store::run_concurrent`:
253///
254/// ```text
255///     Result<Result<T, Inner>, wasmtime::Error>
256///      ─────────────────┬─────────────────────  outer wasmtime::Error:
257///      │              │                       infrastructure trap, epoch
258///      │              │                       interrupt, store access
259///      │              │                       failure
260///      │              └────────────────────  inner `Inner`:
261///      │                                      WIT result, guest trap,
262///      │                                      or domain error
263///      └─────────────────────  success: T
264/// ```
265///
266/// Both layers carry errors that the caller wants to collapse into a
267/// single target error type (`E`). The bindgen shapes vary across
268/// per-world modules (plugin, bean, security-policy) and the inner
269/// type varies too (String, plugin::WasmError, bean::WasmError,
270/// security_policy::WasmError), so the helper takes two closures —
271/// one for each layer — and is fully generic over `T`, `Inner`, and `E`.
272///
273/// This replaces the copy-pasted 6+ `match outer { ... }` / `match
274/// middle { ... }` chain at the call sites in `runtime.rs`,
275/// `wasm_plugin_context.rs`, `bean.rs`, `authorization_policy.rs`, and
276/// `security_policy.rs`.
277pub fn peel_concurrent<T, Inner, E>(
278    result: Result<Result<T, Inner>, wasmtime::Error>,
279    map_outer: impl FnOnce(wasmtime::Error) -> E,
280    map_inner: impl FnOnce(Inner) -> E,
281) -> Result<T, E> {
282    match result {
283        Ok(Ok(v)) => Ok(v),
284        Ok(Err(inner)) => Err(map_inner(inner)),
285        Err(outer) => Err(map_outer(outer)),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_trap_reason_display() {
295        assert_eq!(TrapReason::Timeout.to_string(), "execution timeout");
296        assert_eq!(TrapReason::OutOfMemory.to_string(), "out of memory");
297        assert_eq!(
298            TrapReason::Unreachable.to_string(),
299            "unreachable instruction"
300        );
301        assert_eq!(TrapReason::StackOverflow.to_string(), "stack overflow");
302        assert_eq!(
303            TrapReason::Other("custom".to_string()).to_string(),
304            "custom"
305        );
306    }
307
308    #[test]
309    fn test_classify_trap_stack_overflow() {
310        let trap = wasmtime::Trap::StackOverflow;
311        assert!(matches!(
312            WasmError::classify_trap(&trap),
313            TrapReason::StackOverflow
314        ));
315    }
316
317    #[test]
318    fn test_classify_trap_memory_out_of_bounds() {
319        let trap = wasmtime::Trap::MemoryOutOfBounds;
320        assert!(matches!(
321            WasmError::classify_trap(&trap),
322            TrapReason::OutOfMemory
323        ));
324    }
325
326    #[test]
327    fn test_classify_trap_unreachable() {
328        let trap = wasmtime::Trap::UnreachableCodeReached;
329        assert!(matches!(
330            WasmError::classify_trap(&trap),
331            TrapReason::Unreachable
332        ));
333    }
334
335    #[test]
336    fn test_wasm_error_timeout_display() {
337        let err = WasmError::Timeout {
338            plugin: "my_plugin".to_string(),
339            timeout_secs: 30,
340        };
341        let msg = err.to_string();
342        assert!(msg.contains("my_plugin"));
343        assert!(msg.contains("30"));
344        assert!(msg.contains("timed out"));
345    }
346
347    #[test]
348    fn test_wasm_error_trap_display() {
349        let err = WasmError::Trap {
350            plugin: "my_plugin".to_string(),
351            reason: TrapReason::StackOverflow,
352        };
353        let msg = err.to_string();
354        assert!(msg.contains("my_plugin"));
355        assert!(msg.contains("stack overflow"));
356    }
357
358    #[test]
359    fn test_wasm_error_out_of_memory_display() {
360        let err = WasmError::OutOfMemory {
361            plugin: "my_plugin".to_string(),
362            max_memory_bytes: 52428800,
363        };
364        let msg = err.to_string();
365        assert!(msg.contains("my_plugin"));
366        assert!(msg.contains("52428800"));
367    }
368
369    #[test]
370    fn test_wasm_error_unhealthy_display() {
371        let err = WasmError::Unhealthy {
372            plugin: "my_plugin".to_string(),
373            detail: "consecutive failures".to_string(),
374        };
375        let msg = err.to_string();
376        assert!(msg.contains("my_plugin"));
377        assert!(msg.contains("consecutive failures"));
378    }
379
380    #[test]
381    fn test_wasm_error_to_camel_error_timeout() {
382        let err = WasmError::Timeout {
383            plugin: "p".to_string(),
384            timeout_secs: 10,
385        };
386        let camel: CamelError = err.into();
387        let msg = camel.to_string();
388        assert!(msg.contains("timed out"));
389        assert!(msg.contains("10"));
390    }
391
392    #[test]
393    fn test_wasm_error_to_camel_error_trap() {
394        let err = WasmError::Trap {
395            plugin: "p".to_string(),
396            reason: TrapReason::Unreachable,
397        };
398        let camel: CamelError = err.into();
399        let msg = camel.to_string();
400        assert!(msg.contains("unreachable"));
401    }
402
403    #[test]
404    fn test_wasm_error_to_camel_error_out_of_memory() {
405        let err = WasmError::OutOfMemory {
406            plugin: "p".to_string(),
407            max_memory_bytes: 1024,
408        };
409        let camel: CamelError = err.into();
410        let msg = camel.to_string();
411        assert!(msg.contains("memory"));
412    }
413
414    #[test]
415    fn test_wasm_error_to_camel_error_unhealthy() {
416        let err = WasmError::Unhealthy {
417            plugin: "p".to_string(),
418            detail: "broken".to_string(),
419        };
420        let camel: CamelError = err.into();
421        assert!(matches!(camel, CamelError::ProcessorError(_)));
422    }
423
424    #[test]
425    fn test_guest_panic_still_maps_to_processor_error() {
426        let err = WasmError::GuestPanic("boom".to_string());
427        let camel: CamelError = err.into();
428        assert!(matches!(camel, CamelError::ProcessorError(_)));
429        assert!(camel.to_string().contains("wasm trap"));
430    }
431
432    #[test]
433    fn test_instantiation_maps_to_config_error() {
434        let err = WasmError::InstantiationFailed("bad import".to_string());
435        let camel: CamelError = err.into();
436        assert!(matches!(camel, CamelError::Config(_)));
437        assert!(camel.to_string().contains("instantiation failed"));
438    }
439
440    #[test]
441    fn test_plugin_name() {
442        let err = WasmError::Timeout {
443            plugin: "test".to_string(),
444            timeout_secs: 5,
445        };
446        assert_eq!(err.plugin_name(), Some("test"));
447
448        let err = WasmError::GuestPanic("msg".to_string());
449        assert_eq!(err.plugin_name(), None);
450    }
451
452    // ── peel_concurrent helper (I1) ──────────────────────────────────────
453
454    #[test]
455    fn peel_concurrent_ok_outer_ok_inner_returns_t() {
456        let r: Result<Result<i32, String>, wasmtime::Error> = Ok(Ok(42));
457        let result = peel_concurrent(r, |_| 0, |_| -1);
458        assert_eq!(result, Ok(42));
459    }
460
461    #[test]
462    fn peel_concurrent_ok_outer_err_inner_runs_map_inner() {
463        let r: Result<Result<i32, String>, wasmtime::Error> = Ok(Err("inner".to_string()));
464        let result = peel_concurrent(r, |_| "outer".to_string(), |s| s.to_uppercase());
465        assert_eq!(result, Err("INNER".to_string()));
466    }
467
468    #[test]
469    fn peel_concurrent_err_outer_runs_map_outer() {
470        // Construct a fake wasmtime::Error via the Into function.
471        let wt_err = wasmtime::Error::msg("outer trap");
472        let r: Result<Result<i32, String>, wasmtime::Error> = Err(wt_err);
473        let result = peel_concurrent(r, |_| "outer-mapped".to_string(), |_| "inner".to_string());
474        assert_eq!(result, Err("outer-mapped".to_string()));
475    }
476}