camel-component-wasm 0.9.0

WASM plugin component for rust-camel
Documentation
//! Epoch-based execution timeout enforcement.
//!
//! A background thread that calls `engine.increment_epoch()` at a fixed interval
//! (10ms by default, matching Surrealism's approach). When a WASM guest call
//! has `store.set_epoch_deadline(N)`, wasmtime will raise a trap after N epochs
//! have passed without the deadline being renewed.
//!
//! The EpochTicker is owned by `WasmRuntime` and lives for the lifetime of the
//! runtime. It is stopped (thread joined) when the runtime is dropped.
//!
//! **M6 fix (from review):** Graceful shutdown is implemented via `AtomicBool`:
//! the ticker loop checks `shutdown.load(Ordering::Acquire)` each iteration,
//! and `Drop` sets the flag and joins the thread. This prevents the ticker
//! thread from calling `engine.increment_epoch()` after the engine is dropped.

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;

use wasmtime::Engine;

/// Background thread that increments the wasmtime engine epoch at a fixed interval.
///
/// # Lifecycle
///
/// 1. Created via `EpochTicker::start(engine, interval)`
/// 2. Runs in the background, calling `engine.increment_epoch()` every `interval`
/// 3. Stopped automatically when dropped (sets shutdown flag, joins thread)
pub struct EpochTicker {
    handle: Option<JoinHandle<()>>,
    shutdown: Arc<AtomicBool>,
}

impl EpochTicker {
    /// Start the epoch ticker thread.
    ///
    /// The thread will call `engine.increment_epoch()` every `interval` until
    /// the `EpochTicker` is dropped.
    ///
    /// # Panics
    ///
    /// Panics if the thread cannot be spawned (extremely unlikely).
    pub fn start(engine: Engine, interval: Duration) -> Self {
        let shutdown = Arc::new(AtomicBool::new(false));
        let shutdown_flag = Arc::clone(&shutdown);

        let handle = thread::Builder::new()
            .name("wasm-epoch-ticker".to_string())
            .spawn(move || {
                while !shutdown_flag.load(Ordering::Acquire) {
                    thread::sleep(interval);
                    if !shutdown_flag.load(Ordering::Acquire) {
                        engine.increment_epoch();
                    }
                }
            })
            .expect("failed to spawn wasm-epoch-ticker thread");

        Self {
            handle: Some(handle),
            shutdown,
        }
    }

    /// Check if the ticker is still running.
    pub fn is_running(&self) -> bool {
        !self.shutdown.load(Ordering::Acquire)
    }
}

impl Drop for EpochTicker {
    fn drop(&mut self) {
        self.shutdown.store(true, Ordering::Release);
        if let Some(handle) = self.handle.take() {
            // The thread will exit within one interval (10ms).
            // Don't block forever if the thread is stuck.
            let _ = handle.join();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use wasmtime::Config;

    fn create_test_engine() -> Engine {
        let mut config = Config::new();
        config.epoch_interruption(true);
        Engine::new(&config).expect("failed to create test engine")
    }

    #[test]
    fn test_epoch_ticker_starts_and_stops() {
        let engine = create_test_engine();
        let ticker = EpochTicker::start(engine, Duration::from_millis(10));
        assert!(ticker.is_running());
        drop(ticker);
        // After drop, the thread should have stopped
    }

    #[test]
    fn test_epoch_ticker_increments_epoch() {
        let engine = create_test_engine();
        let ticker = EpochTicker::start(engine.clone(), Duration::from_millis(5));

        // Wait for at least 2 ticks — if ticker works, no panic occurs.
        // `current_epoch()` is pub(crate) in wasmtime 31, so we verify
        // liveness indirectly by confirming the thread stays alive.
        std::thread::sleep(Duration::from_millis(20));
        assert!(
            ticker.is_running(),
            "ticker should still be running after 20ms"
        );

        drop(ticker);
    }

    #[test]
    fn test_epoch_ticker_stops_cleanly() {
        let engine = create_test_engine();
        let ticker = EpochTicker::start(engine, Duration::from_millis(10));
        assert!(ticker.is_running());

        drop(ticker);

        // Verify we can create a new ticker after dropping the old one
        let engine2 = create_test_engine();
        let ticker2 = EpochTicker::start(engine2, Duration::from_millis(10));
        assert!(ticker2.is_running());
        drop(ticker2);
    }

    #[test]
    fn test_multiple_tickers_on_different_engines() {
        let engine1 = create_test_engine();
        let engine2 = create_test_engine();

        let ticker1 = EpochTicker::start(engine1, Duration::from_millis(10));
        let ticker2 = EpochTicker::start(engine2, Duration::from_millis(10));

        assert!(ticker1.is_running());
        assert!(ticker2.is_running());

        drop(ticker1);
        assert!(ticker2.is_running());
        drop(ticker2);
    }
}