Skip to main content

rill_runtime/handler/
wasm.rs

1//! Sandboxed WASM handler adapter.
2//!
3//! Loads a signed `.rillhandler` module, instantiates it inside a Wasmtime
4//! component sandbox with strict resource limits, and adapts it to the
5//! [`InvokeHandler`](crate::server::InvokeHandler) trait.
6//!
7//! ## Sandbox guarantees
8//!
9//! - No WASI imports (no filesystem, network, environment, stdio, process).
10//! - Fuel budget per `configure`/`invoke` call.
11//! - Epoch interruption for wall-clock timeout.
12//! - Memory and table growth capped by `HostState` (implements [`ResourceLimiter`]).
13//! - Input and output JSON bounded by [`MAX_IO_BYTES`].
14
15use std::sync::{
16    Arc, Mutex,
17    atomic::{AtomicBool, Ordering},
18};
19use std::time::Duration;
20
21use serde_json::Value;
22use wasmtime::component::{Component, Linker};
23use wasmtime::{Config, Engine, ResourceLimiter, Store, Trap};
24
25use crate::handler::HandlerLoadError;
26use crate::handler_package::LoadedHandlerPack;
27use crate::server::{InvokeError, InvokeErrorKind, InvokeHandler as InvokeHandlerTrait};
28
29// Generate host bindings from the canonical WIT world. The macro emits an
30// `invoke_handler` module containing the `InvokeHandler` instance struct.
31//
32// The WIT source is copied from `crates/rill-handler-api/wit/rill-handler.wit`
33// into `crates/rill-runtime/wit/rill-handler.wit` so that `cargo package` can
34// build the tarball self-contained — the relative `../rill-handler-api/...`
35// path used in development does not exist inside the packaged tarball. The
36// `scripts/check_wit_abi.py` CI gate verifies the two copies stay identical.
37mod invoke_handler {
38    wasmtime::component::bindgen!({
39        path: "wit/rill-handler.wit",
40        world: "invoke-handler",
41    });
42}
43
44/// Fuel budget for a single `configure` call.
45pub const CONFIGURE_FUEL: u64 = 10_000_000;
46/// Fuel budget for a single `invoke` call.
47///
48/// Handler input and output are allowed to reach 1 MiB. The previous one-million
49/// unit budget could be exhausted by ordinary JSON decoding before a handler's
50/// algorithm ran (Mira's battery handler reproduced this with roughly 100
51/// samples). The epoch deadline remains the authoritative five-second wall-clock
52/// guard, while this larger deterministic budget lets valid bounded payloads run.
53pub const INVOKE_FUEL: u64 = 100_000_000;
54/// Maximum linear memory size per instance (64 MiB).
55pub const MAX_MEMORY_BYTES: usize = 64 * 1024 * 1024;
56/// Maximum table entries per instance.
57pub const MAX_TABLE_ELEMENTS: u32 = 10_000;
58/// Maximum input/output JSON payload size (1 MiB, matches IPC limit).
59pub const MAX_IO_BYTES: usize = 1024 * 1024;
60/// Epoch tick interval (1 second).
61pub const EPOCH_TICK_INTERVAL: Duration = Duration::from_secs(1);
62/// Number of epoch ticks before interruption (5 seconds).
63pub const EPOCH_DEADLINE: u64 = 5;
64
65// Test-only counter of live epoch-ticker threads. Incremented at thread
66// entry and decremented before thread exit, so a non-zero value means a
67// ticker thread is still running. Used by the internal ticker-lifecycle
68// unit tests in `mod tests` below to directly observe that failed handler
69// loads and handler drops join the background thread.
70//
71// This counter, the matching `fetch_add`/`fetch_sub` ops in
72// `EpochTicker::start`, and the `active_epoch_ticker_count` accessor in
73// `mod tests` are all `#[cfg(test)]`-only: they do not exist in release
74// builds, in CI builds that link the library as a dependency (integration
75// tests), or in the published crate. The production ticker hot path
76// performs zero atomic operations for instrumentation. The previous
77// design exposed a `#[doc(hidden)] pub fn active_epoch_ticker_count()`
78// so that integration tests (a separate crate) could reach the counter;
79// that leaked a test probe into the public API. The current design moves
80// the lifecycle tests into this module's internal `#[cfg(test)] mod
81// tests`, which can reach private items directly through `super::*`.
82#[cfg(test)]
83static ACTIVE_EPOCH_TICKERS: std::sync::atomic::AtomicUsize =
84    std::sync::atomic::AtomicUsize::new(0);
85
86/// Per-instance resource limiter enforcing memory and table caps.
87struct HostState;
88
89impl ResourceLimiter for HostState {
90    fn memory_growing(
91        &mut self,
92        _current: usize,
93        desired: usize,
94        _max: Option<usize>,
95    ) -> Result<bool, wasmtime::Error> {
96        Ok(desired <= MAX_MEMORY_BYTES)
97    }
98
99    fn table_growing(
100        &mut self,
101        _current: usize,
102        desired: usize,
103        _max: Option<usize>,
104    ) -> Result<bool, wasmtime::Error> {
105        Ok(desired <= MAX_TABLE_ELEMENTS as usize)
106    }
107}
108
109struct WasmState {
110    store: Store<HostState>,
111    bindings: invoke_handler::InvokeHandler,
112}
113
114/// RAII guard for the background epoch-ticker thread.
115///
116/// The ticker must be running before any guest code is invoked so that
117/// `metadata()`, `configure()` and `invoke()` are all bounded by the
118/// epoch-deadline wall-clock timeout. Dropping the guard signals the thread
119/// to stop and joins it; if init fails, dropping this guard ensures no
120/// background thread is leaked.
121struct EpochTicker {
122    stop_flag: Arc<AtomicBool>,
123    handle: Option<std::thread::JoinHandle<()>>,
124}
125
126impl EpochTicker {
127    /// Start the ticker. The thread sleeps for [`EPOCH_TICK_INTERVAL`] then
128    /// calls `engine.increment_epoch()` until [`Self::stop`] is called.
129    fn start(engine: Engine) -> Self {
130        let stop_flag = Arc::new(AtomicBool::new(false));
131        let engine_for_thread = engine;
132        let stop_for_thread = Arc::clone(&stop_flag);
133        let handle = std::thread::spawn(move || {
134            // Increment the test-only active-ticker counter at thread entry
135            // so the count reflects threads that have actually started. The
136            // matching decrement runs just before the thread exits (after
137            // the stop flag is observed), so a non-zero count means a
138            // ticker is still running. `Drop` joins the handle, which
139            // guarantees the decrement has happened by the time drop
140            // returns.
141            //
142            // The counter and these atomic ops are `#[cfg(test)]`-gated, so
143            // the production ticker hot path performs zero atomic
144            // operations for instrumentation. The whole counter does not
145            // exist in release builds, in CI builds that link the library
146            // as a dependency (integration tests), or in the published
147            // crate. The internal `mod tests` below reaches the counter
148            // directly through `super::*`, so no `pub` accessor is needed.
149            #[cfg(test)]
150            ACTIVE_EPOCH_TICKERS.fetch_add(1, Ordering::SeqCst);
151            while !stop_for_thread.load(Ordering::Relaxed) {
152                std::thread::sleep(EPOCH_TICK_INTERVAL);
153                engine_for_thread.increment_epoch();
154            }
155            #[cfg(test)]
156            ACTIVE_EPOCH_TICKERS.fetch_sub(1, Ordering::SeqCst);
157        });
158        Self {
159            stop_flag,
160            handle: Some(handle),
161        }
162    }
163}
164
165impl Drop for EpochTicker {
166    fn drop(&mut self) {
167        self.stop_flag.store(true, Ordering::Relaxed);
168        if let Some(handle) = self.handle.take() {
169            // The thread sleeps for at most one tick before observing the
170            // stop flag, so join waits at most ~1 second.
171            let _ = handle.join();
172        }
173    }
174}
175
176/// Sandboxed WASM handler that implements [`crate::server::InvokeHandler`].
177///
178/// The handler holds a Wasmtime [`Engine`], a background epoch-ticker thread,
179/// and a [`Mutex`] protecting the [`Store`] and component instance. Calls are
180/// serialised by the mutex; the first version does not support parallel
181/// invocation.
182pub struct WasmInvokeHandler {
183    engine: Engine,
184    _ticker: EpochTicker,
185    state: Mutex<WasmState>,
186}
187
188impl WasmInvokeHandler {
189    /// Load and instantiate a signed handler pack.
190    ///
191    /// Verifies that guest `metadata()` matches the signed manifest, then calls
192    /// `configure()` with the canonical model JSON. Returns an error if any
193    /// step fails; no partial state is retained.
194    ///
195    /// The epoch ticker is started before component instantiation so that
196    /// `metadata()`, `configure()` and every later `invoke()` all run under
197    /// the same wall-clock deadline. Fuel is reset before each call so the
198    /// budgets do not pool across stages.
199    pub fn new(pack: &LoadedHandlerPack, model_json: &Value) -> Result<Self, HandlerLoadError> {
200        let mut config = Config::new();
201        config.consume_fuel(true);
202        config.epoch_interruption(true);
203        config.max_wasm_stack(1024 * 1024);
204
205        let engine = Engine::new(&config)
206            .map_err(|e| HandlerLoadError::Init(format!("engine creation failed: {e}")))?;
207        // Start the epoch ticker before any guest code runs. If a later
208        // step fails, the `EpochTicker` guard dropped at the end of this
209        // function (or by `?` propagation) stops the thread.
210        let ticker = EpochTicker::start(engine.clone());
211
212        let component = Component::new(&engine, &pack.module)
213            .map_err(|e| HandlerLoadError::Init(format!("component compilation failed: {e}")))?;
214
215        let linker: Linker<HostState> = Linker::new(&engine);
216        let mut store = Store::new(&engine, HostState);
217        store.limiter(|state| state as &mut dyn ResourceLimiter);
218
219        // Stage 1: component instantiation. Fresh fuel + deadline.
220        store
221            .set_fuel(CONFIGURE_FUEL)
222            .map_err(|e| HandlerLoadError::Init(format!("failed to set instantiate fuel: {e}")))?;
223        store.set_epoch_deadline(EPOCH_DEADLINE);
224        let bindings = invoke_handler::InvokeHandler::instantiate(&mut store, &component, &linker)
225            .map_err(|e| HandlerLoadError::Init(format!("instantiation failed: {e}")))?;
226
227        // Stage 2: metadata(). Fresh fuel + deadline so it cannot inherit
228        // leftover fuel from instantiation.
229        store
230            .set_fuel(CONFIGURE_FUEL)
231            .map_err(|e| HandlerLoadError::Init(format!("failed to set metadata fuel: {e}")))?;
232        store.set_epoch_deadline(EPOCH_DEADLINE);
233        let metadata = bindings
234            .call_metadata(&mut store)
235            .map_err(|e| HandlerLoadError::Init(format!("metadata trap: {e}")))?;
236        if metadata.id != pack.manifest.id {
237            return Err(HandlerLoadError::MetadataMismatch(format!(
238                "guest id '{}' != manifest id '{}'",
239                metadata.id, pack.manifest.id
240            )));
241        }
242        if metadata.version != pack.manifest.version {
243            return Err(HandlerLoadError::MetadataMismatch(format!(
244                "guest version '{}' != manifest version '{}'",
245                metadata.version, pack.manifest.version
246            )));
247        }
248        if metadata.api_version != pack.manifest.handler_api_version {
249            return Err(HandlerLoadError::MetadataMismatch(format!(
250                "guest api version {} != manifest api version {}",
251                metadata.api_version, pack.manifest.handler_api_version
252            )));
253        }
254        let mut manifest_caps = pack.manifest.capabilities.clone();
255        manifest_caps.sort();
256        let mut metadata_caps = metadata.capabilities.clone();
257        metadata_caps.sort();
258        if manifest_caps != metadata_caps {
259            return Err(HandlerLoadError::MetadataMismatch(
260                "guest capabilities != manifest capabilities".into(),
261            ));
262        }
263
264        // Stage 3: configure(). Fresh fuel + deadline.
265        let model_bytes = serde_json::to_vec(model_json)
266            .map_err(|e| HandlerLoadError::Init(format!("model serialization failed: {e}")))?;
267        if model_bytes.len() > MAX_IO_BYTES {
268            return Err(HandlerLoadError::Init("model JSON exceeds limit".into()));
269        }
270        store
271            .set_fuel(CONFIGURE_FUEL)
272            .map_err(|e| HandlerLoadError::Init(format!("failed to set configure fuel: {e}")))?;
273        store.set_epoch_deadline(EPOCH_DEADLINE);
274        let configure_result = bindings
275            .call_configure(&mut store, &model_bytes)
276            .map_err(|e| HandlerLoadError::Init(format!("configure trap: {e}")))?;
277        if let Err(handler_error) = configure_result {
278            // Map each WIT variant to extract the guest-supplied detail
279            // string. The variant name is included in the load error for
280            // host-side diagnostics; the guest detail is truncated by
281            // the caller's formatting. This avoids leaking the Rust type
282            // name (`HandlerError::VariantName`) that the previous
283            // `{handler_error:?}` Debug format exposed.
284            let (variant, detail) = match handler_error {
285                invoke_handler::HandlerError::InvalidModel(s) => ("invalid-model", s),
286                invoke_handler::HandlerError::InvalidInput(s) => ("invalid-input", s),
287                invoke_handler::HandlerError::UnsupportedCapability(s) => {
288                    ("unsupported-capability", s)
289                }
290                invoke_handler::HandlerError::ExecutionFailed(s) => ("execution-failed", s),
291            };
292            return Err(HandlerLoadError::Init(format!(
293                "configure rejected model ({variant}): {detail}"
294            )));
295        }
296
297        Ok(Self {
298            engine,
299            _ticker: ticker,
300            state: Mutex::new(WasmState { store, bindings }),
301        })
302    }
303
304    /// Returns the engine reference (needed for external epoch control if any).
305    #[allow(dead_code)]
306    pub fn engine(&self) -> &Engine {
307        &self.engine
308    }
309}
310
311impl std::fmt::Debug for WasmInvokeHandler {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        f.debug_struct("WasmInvokeHandler")
314            .field(
315                "epoch_ticker_running",
316                &!self._ticker.stop_flag.load(Ordering::Relaxed),
317            )
318            .finish_non_exhaustive()
319    }
320}
321
322impl InvokeHandlerTrait for WasmInvokeHandler {
323    fn invoke(&self, capability: &str, input: &Value) -> Result<Value, InvokeError> {
324        let input_bytes = serde_json::to_vec(input).map_err(|e| {
325            InvokeError::with_detail(
326                InvokeErrorKind::Internal,
327                format!("input serialization failed: {e}"),
328            )
329        })?;
330        if input_bytes.len() > MAX_IO_BYTES {
331            return Err(InvokeError::new(InvokeErrorKind::Internal));
332        }
333
334        let mut state = self.state.lock().map_err(|_| {
335            // A poisoned mutex indicates a panic in a previous call; the
336            // handler is no longer usable. Surface this as an internal
337            // error rather than crashing the runtime.
338            InvokeError::with_detail(InvokeErrorKind::Internal, "handler state mutex poisoned")
339        })?;
340
341        state.store.set_fuel(INVOKE_FUEL).map_err(|e| {
342            InvokeError::with_detail(
343                InvokeErrorKind::Internal,
344                format!("failed to set invoke fuel: {e}"),
345            )
346        })?;
347        state.store.set_epoch_deadline(EPOCH_DEADLINE);
348
349        // Destructure to avoid simultaneous immutable borrow of `bindings` and
350        // mutable borrow of `store` through the same `MutexGuard`.
351        let WasmState { store, bindings } = &mut *state;
352        let result = bindings
353            .call_invoke(store, capability, &input_bytes)
354            .map_err(|e| {
355                // Map fuel exhaustion and epoch interruption to handlerTimeout.
356                // Wasmtime 46's Error Display wraps the trap in a WasmBacktrace
357                // context, so string matching on the Display is unreliable;
358                // downcast to the concrete Trap variant instead.
359                if let Some(trap) = e.downcast_ref::<Trap>()
360                    && matches!(trap, Trap::OutOfFuel | Trap::Interrupt)
361                {
362                    return InvokeError::new(InvokeErrorKind::Timeout);
363                }
364                // The wasmtime Display string may include a full WASM
365                // backtrace (guest-controlled). Construct `InvokeError`
366                // first — `with_detail` truncates to `MAX_DETAIL_BYTES` —
367                // and do NOT log here. The `RuntimeEngine` layer logs the
368                // already-truncated detail exactly once (see audit 5.2).
369                InvokeError::with_detail(InvokeErrorKind::Trap, format!("{e}"))
370            })?;
371
372        match result {
373            Ok(output_bytes) => {
374                if output_bytes.len() > MAX_IO_BYTES {
375                    return Err(InvokeError::new(InvokeErrorKind::OutputTooLarge));
376                }
377                serde_json::from_slice(&output_bytes).map_err(|e| {
378                    InvokeError::with_detail(
379                        InvokeErrorKind::InvalidOutput,
380                        format!("host-side JSON deserialisation failed: {e}"),
381                    )
382                })
383            }
384            Err(handler_error) => {
385                // Guest reported a typed `handler-error` variant. Map
386                // each WIT variant to the corresponding `InvokeErrorKind`
387                // and extract the inner detail string (which is fully
388                // guest-controlled). `InvokeError::with_detail` truncates
389                // the detail to `MAX_DETAIL_BYTES` on a UTF-8 char
390                // boundary. The adapter does NOT log the error — the
391                // `RuntimeEngine` layer logs the already-truncated detail
392                // exactly once (see audit 5.1 + 5.2).
393                let (kind, detail) = match handler_error {
394                    invoke_handler::HandlerError::InvalidModel(s) => {
395                        (InvokeErrorKind::InvalidModel, s)
396                    }
397                    invoke_handler::HandlerError::InvalidInput(s) => {
398                        (InvokeErrorKind::InvalidInput, s)
399                    }
400                    invoke_handler::HandlerError::UnsupportedCapability(s) => {
401                        (InvokeErrorKind::UnsupportedCapability, s)
402                    }
403                    invoke_handler::HandlerError::ExecutionFailed(s) => {
404                        (InvokeErrorKind::ExecutionFailed, s)
405                    }
406                };
407                Err(InvokeError::with_detail(kind, detail))
408            }
409        }
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    //! Unit tests for the `ResourceLimiter` implementation on `HostState`.
416    //!
417    //! The sandbox caps linear memory and table growth (see `MAX_MEMORY_BYTES`
418    //! and `MAX_TABLE_ELEMENTS`). These tests verify the limiter directly so
419    //! that a future refactor that weakens the caps is caught without
420    //! requiring a malicious WASM component that tries to grow memory/table
421    //! past the limits (which would be hard to author portably).
422
423    use super::*;
424
425    #[test]
426    fn memory_limiter_accepts_growth_within_max() {
427        let mut state = HostState;
428        // Growth to exactly the cap is allowed.
429        assert!(
430            state
431                .memory_growing(0, MAX_MEMORY_BYTES, None)
432                .expect("memory_growing must not error")
433        );
434        // Growth below the cap is allowed.
435        assert!(
436            state
437                .memory_growing(0, MAX_MEMORY_BYTES - 1, None)
438                .expect("memory_growing must not error")
439        );
440    }
441
442    #[test]
443    fn memory_limiter_rejects_growth_exceeding_max() {
444        let mut state = HostState;
445        // Growth one byte beyond the cap is rejected.
446        assert!(
447            !state
448                .memory_growing(MAX_MEMORY_BYTES - 1, MAX_MEMORY_BYTES + 1, None)
449                .expect("memory_growing must not error")
450        );
451        // Growth far beyond the cap is rejected.
452        assert!(
453            !state
454                .memory_growing(0, MAX_MEMORY_BYTES * 2, None)
455                .expect("memory_growing must not error")
456        );
457    }
458
459    #[test]
460    fn table_limiter_accepts_growth_within_max() {
461        let mut state = HostState;
462        // Growth to exactly the cap is allowed.
463        assert!(
464            state
465                .table_growing(0, MAX_TABLE_ELEMENTS as usize, None)
466                .expect("table_growing must not error")
467        );
468        // Growth below the cap is allowed.
469        assert!(
470            state
471                .table_growing(0, (MAX_TABLE_ELEMENTS - 1) as usize, None)
472                .expect("table_growing must not error")
473        );
474    }
475
476    #[test]
477    fn table_limiter_rejects_growth_exceeding_max() {
478        let mut state = HostState;
479        // Growth one element beyond the cap is rejected.
480        assert!(
481            !state
482                .table_growing(
483                    (MAX_TABLE_ELEMENTS - 1) as usize,
484                    (MAX_TABLE_ELEMENTS + 1) as usize,
485                    None
486                )
487                .expect("table_growing must not error")
488        );
489        // Growth far beyond the cap is rejected.
490        assert!(
491            !state
492                .table_growing(0, (MAX_TABLE_ELEMENTS * 2) as usize, None)
493                .expect("table_growing must not error")
494        );
495    }
496
497    // -----------------------------------------------------------------
498    // Ticker lifecycle observability (audit 6.5 / fourth-stage 4-A-01
499    // / fifth-stage 5-A-01..5-A-03).
500    //
501    // The `EpochTicker` RAII guard starts a background thread that
502    // periodically calls `engine.increment_epoch()`. The tests below use
503    // the test-only `ACTIVE_EPOCH_TICKERS` counter (and the private
504    // `active_epoch_ticker_count` accessor defined in this module) to
505    // directly observe that the thread is started on handler
506    // construction and joined on handler drop or load failure.
507    //
508    // These tests were previously in `tests/wasm_handler.rs` and reached
509    // the counter through a `#[doc(hidden)] pub` accessor, which leaked
510    // a test probe into the public API. They have been moved here so the
511    // counter and accessor can both be `#[cfg(test)]`-private.
512    //
513    // Fifth-stage strengthening:
514    // - The metadata-loop test now spawns the constructor in a worker
515    //   thread so the main thread can observe `baseline + 1` *during*
516    //   construction (previously the synchronous flow could pass even if
517    //   the ticker never started, because by the time `new()` returned
518    //   `Err` the constructor had already joined the thread).
519    // - Fixture gating now respects `RILL_RUN_WASM_FIXTURE_TESTS=1`:
520    //   the dedicated `wasm-handler` CI job sets this env var so a
521    //   regression in fixture production surfaces as a hard CI failure
522    //   rather than a silently-skipped test. The regular workspace
523    //   `cargo test` job (which does not build the fixtures) still
524    //   skips gracefully.
525    // - The previously-named `ticker_probe_is_not_in_public_api` test
526    //   was renamed to `ticker_probe_is_available_to_internal_tests`
527    //   because it does *not* actually assert anything about the public
528    //   API. The primary public-API leak check is the external compile-fail
529    //   crate in `scripts/check_runtime_public_api.py` (run by the
530    //   `wasm-handler` CI job); the `cargo doc` + `grep` step in the same
531    //   job is an auxiliary smoke check that only catches probes which are
532    //   both `pub` and not `#[doc(hidden)]`.
533    // -----------------------------------------------------------------
534
535    use std::collections::BTreeMap;
536    use std::fs;
537    use std::path::PathBuf;
538    use std::sync::Arc;
539
540    use ed25519_dalek::{SigningKey, VerifyingKey};
541    use rill_runtime_protocol::{
542        HANDLER_API_VERSION, HANDLER_PACKAGE_FORMAT_VERSION, HandlerPackManifest,
543    };
544    use sha2::{Digest, Sha256};
545
546    /// Serialises lifecycle tests in this module so their assertions
547    /// about the global `ACTIVE_EPOCH_TICKERS` counter are not perturbed
548    /// by parallel ticker creation/drop. The guard is recovered from
549    /// poison so a panic in one test does not cascade.
550    static LIFECYCLE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
551
552    /// Test-only accessor for the count of currently-running epoch-ticker
553    /// threads. Private to this module — does not exist in non-test
554    /// builds, cannot be reached from external crates or integration
555    /// tests.
556    fn active_epoch_ticker_count() -> usize {
557        ACTIVE_EPOCH_TICKERS.load(Ordering::SeqCst)
558    }
559
560    /// Acquires the lifecycle test serialisation lock.
561    fn lifecycle_guard() -> std::sync::MutexGuard<'static, ()> {
562        LIFECYCLE_TEST_LOCK
563            .lock()
564            .unwrap_or_else(|poisoned| poisoned.into_inner())
565    }
566
567    /// Polls `active_epoch_ticker_count` until it reaches `target` or
568    /// `timeout` elapses. Uses 10 ms polling to avoid flaky fixed sleeps
569    /// while bounding wait time.
570    fn wait_for_active_ticker_count(target: usize, timeout: std::time::Duration) -> bool {
571        let start = std::time::Instant::now();
572        loop {
573            if active_epoch_ticker_count() == target {
574                return true;
575            }
576            if start.elapsed() >= timeout {
577                return false;
578            }
579            std::thread::sleep(std::time::Duration::from_millis(10));
580        }
581    }
582
583    /// Resolves a WASM fixture path for the lifecycle tests.
584    ///
585    /// Resolution order:
586    /// 1. The `env_name` environment variable (e.g. `ECHO_HANDLER_WASM`) if
587    ///    set. The path must point to an existing file — a missing file
588    ///    panics so a misconfigured CI step cannot silently skip the test.
589    /// 2. The workspace-relative fallback under `target/`.
590    /// 3. If neither exists and `RILL_RUN_WASM_FIXTURE_TESTS` is **not**
591    ///    set, returns `None` so the regular workspace `cargo test` job
592    ///    (which does not build the WASM fixtures) can skip the test
593    ///    without failing.
594    /// 4. If neither exists and `RILL_RUN_WASM_FIXTURE_TESTS=1` is set,
595    ///    panics — the dedicated CI job must fail loudly instead of
596    ///    silently reporting green.
597    ///
598    /// The dedicated `wasm-handler` CI job sets
599    /// `RILL_RUN_WASM_FIXTURE_TESTS=1` after building the fixtures so
600    /// that a regression in fixture production surfaces as a hard CI
601    /// failure rather than a skipped test.
602    fn fixture_path(env_name: &str, fallback_relative: &str) -> Option<PathBuf> {
603        if let Ok(value) = std::env::var(env_name) {
604            let path = PathBuf::from(value);
605            assert!(
606                path.is_file(),
607                "{env_name} points to missing fixture: {}",
608                path.display()
609            );
610            return Some(path);
611        }
612
613        let fallback = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(fallback_relative);
614        if fallback.is_file() {
615            return Some(fallback);
616        }
617
618        if std::env::var_os("RILL_RUN_WASM_FIXTURE_TESTS").is_some() {
619            panic!(
620                "{env_name} is not set and fallback fixture does not exist: {} \
621                 (RILL_RUN_WASM_FIXTURE_TESTS=1 is set, so missing fixtures must fail)",
622                fallback.display()
623            );
624        }
625
626        None
627    }
628
629    /// Returns the echo handler WASM component path, or `None` if not
630    /// available and fixture tests are not mandatory. Mirrors
631    /// `tests/wasm_handler.rs::echo_handler_component`.
632    fn echo_handler_component() -> Option<PathBuf> {
633        fixture_path("ECHO_HANDLER_WASM", "../../target/echo-handler.wasm")
634    }
635
636    /// Returns the metadata-loop handler WASM component path, or `None`.
637    fn metadata_loop_handler_component() -> Option<PathBuf> {
638        fixture_path(
639            "METADATA_LOOP_HANDLER_WASM",
640            "../../target/test-metadata-loop-handler.wasm",
641        )
642    }
643
644    /// Builds a signed `.rillhandler` pack from the echo handler.
645    fn build_echo_pack(module: &[u8], signing: &SigningKey) -> Vec<u8> {
646        let manifest = HandlerPackManifest {
647            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
648            id: "rillml.echo.handler".into(),
649            version: env!("CARGO_PKG_VERSION").into(),
650            handler_api_version: HANDLER_API_VERSION,
651            min_runtime_version: env!("CARGO_PKG_VERSION").into(),
652            publisher_key_id: "wasm-test-key".into(),
653            capabilities: vec!["rillml.linearRegression.predict".into()],
654            module_sha256: hex::encode(Sha256::digest(module)),
655            module_size: module.len() as u64,
656        };
657        crate::build_signed_handler_pack(&manifest, module, signing).unwrap()
658    }
659
660    /// Builds a signed `.rillhandler` pack from the metadata-loop
661    /// handler. The manifest id matches the guest's `metadata()` return
662    /// value — but since `metadata()` loops forever, the host never
663    /// reaches the mismatch check.
664    fn build_metadata_loop_pack(module: &[u8], signing: &SigningKey) -> Vec<u8> {
665        let manifest = HandlerPackManifest {
666            format_version: HANDLER_PACKAGE_FORMAT_VERSION,
667            id: "rillml.test.metadata-loop".into(),
668            version: env!("CARGO_PKG_VERSION").into(),
669            handler_api_version: HANDLER_API_VERSION,
670            min_runtime_version: env!("CARGO_PKG_VERSION").into(),
671            publisher_key_id: "wasm-test-key".into(),
672            capabilities: vec!["rillml.linearRegression.predict".into()],
673            module_sha256: hex::encode(Sha256::digest(module)),
674            module_size: module.len() as u64,
675        };
676        crate::build_signed_handler_pack(&manifest, module, signing).unwrap()
677    }
678
679    /// Loads a signed pack, returning the `LoadedHandlerPack`.
680    fn load_pack(pack_bytes: &[u8], verifying: &VerifyingKey) -> crate::LoadedHandlerPack {
681        let trust = crate::TrustStore(BTreeMap::from([("wasm-test-key".into(), *verifying)]));
682        let (loaded, _) =
683            crate::load_handler_pack(std::io::Cursor::new(pack_bytes), &trust).unwrap();
684        loaded
685    }
686
687    /// Verifies that constructing a normal echo handler increments the
688    /// active ticker count, and dropping it restores the count. This
689    /// directly proves the ticker thread is started on construction and
690    /// joined on drop.
691    #[test]
692    fn normal_handler_drop_restores_active_ticker_count() {
693        let _guard = lifecycle_guard();
694
695        let component = match echo_handler_component() {
696            Some(path) => fs::read(&path).unwrap(),
697            None => {
698                eprintln!(
699                    "skipping: echo handler component not built \
700                     (set ECHO_HANDLER_WASM or RILL_RUN_WASM_FIXTURE_TESTS=1)"
701                );
702                return;
703            }
704        };
705
706        let signing = SigningKey::from_bytes(&[7; 32]);
707        let pack_bytes = build_echo_pack(&component, &signing);
708        let loaded = load_pack(&pack_bytes, &signing.verifying_key());
709
710        let baseline = active_epoch_ticker_count();
711
712        let model =
713            serde_json::json!({"kind": "linearRegression", "weights": [0.5], "intercept": 0.0});
714        let handler =
715            WasmInvokeHandler::new(&loaded, &model).expect("echo handler must load successfully");
716
717        // The ticker thread increments the count at entry. Wait for the
718        // increment to be visible.
719        assert!(
720            wait_for_active_ticker_count(baseline + 1, std::time::Duration::from_secs(3)),
721            "active ticker count did not reach {} after handler construction (got {}, baseline {})",
722            baseline + 1,
723            active_epoch_ticker_count(),
724            baseline
725        );
726
727        // Dropping the handler must join the ticker thread and restore
728        // the count to baseline.
729        drop(handler);
730
731        assert!(
732            wait_for_active_ticker_count(baseline, std::time::Duration::from_secs(3)),
733            "active ticker count did not return to baseline {} after handler drop (got {})",
734            baseline,
735            active_epoch_ticker_count()
736        );
737    }
738
739    /// Verifies that a failed metadata-loop handler load does not leak
740    /// the epoch-ticker thread — and crucially, that the ticker was
741    /// actually *started* during construction (not just absent
742    /// throughout). The previous synchronous test could pass even if the
743    /// ticker never started, because by the time `new()` returned `Err`
744    /// the constructor had already joined the ticker thread, leaving the
745    /// counter at baseline.
746    ///
747    /// The strengthened flow:
748    /// 1. Record `baseline` ticker count.
749    /// 2. Spawn a worker thread that calls `WasmInvokeHandler::new`,
750    ///    sending its result over an `mpsc::channel` so the main thread
751    ///    can wait with `recv_timeout` instead of an unbounded `join()`.
752    /// 3. From the main test thread, observe `count == baseline + 1`
753    ///    while the constructor is still blocked inside `metadata()`
754    ///    (which loops forever and is interrupted by the epoch deadline).
755    /// 4. Wait for the worker to return — with a test-level timeout so
756    ///    a regression in epoch interruption fails promptly instead of
757    ///    hanging the CI job for 30 minutes. The result must be `Err`
758    ///    because `metadata()` cannot complete within the epoch budget.
759    /// 5. After the worker returns, observe `count == baseline`,
760    ///    proving the `EpochTicker` guard was dropped during error
761    ///    propagation and joined its background thread.
762    ///
763    /// `LoadedHandlerPack` is `Send + Sync` (its fields are
764    /// `HandlerPackManifest` of plain scalars/strings and a `Vec<u8>`),
765    /// so the worker thread takes an `Arc<LoadedHandlerPack>` rather
766    /// than moving the owned value. No production type needs to grow
767    /// `Send`/`Sync` bounds for this test.
768    #[test]
769    fn metadata_loop_failure_restores_active_ticker_count() {
770        let _guard = lifecycle_guard();
771
772        let component = match metadata_loop_handler_component() {
773            Some(path) => fs::read(&path).unwrap(),
774            None => {
775                eprintln!(
776                    "skipping: metadata-loop handler component not built \
777                     (set METADATA_LOOP_HANDLER_WASM or RILL_RUN_WASM_FIXTURE_TESTS=1)"
778                );
779                return;
780            }
781        };
782
783        let signing = SigningKey::from_bytes(&[9; 32]);
784        let pack_bytes = build_metadata_loop_pack(&component, &signing);
785        let loaded = Arc::new(load_pack(&pack_bytes, &signing.verifying_key()));
786
787        let baseline = active_epoch_ticker_count();
788
789        // Spawn the constructor in a worker thread so the main thread
790        // can observe the active-ticker counter while the constructor
791        // is still blocked inside the metadata-loop guest. The worker
792        // sends its result over a channel; the main thread uses
793        // `recv_timeout` to bound the wait, so a regression in epoch
794        // interruption or worker exit logic fails the test promptly
795        // instead of waiting for the CI job's 30-minute timeout.
796        //
797        // `tx.send` only fails if `rx` is dropped, which only happens
798        // if the test framework gives up; in normal operation `rx` is
799        // alive until after `recv_timeout`. If the worker panics
800        // before reaching `send`, `tx` is dropped by unwind and
801        // `recv_timeout` returns `Disconnected` — the main thread
802        // then re-raises the panic payload so the failure points at
803        // the actual panic site instead of a generic channel error.
804        let (tx, rx) = std::sync::mpsc::channel();
805        let worker_loaded = Arc::clone(&loaded);
806        let worker = std::thread::spawn(move || {
807            let result = WasmInvokeHandler::new(&worker_loaded, &serde_json::json!({}));
808            let _ = tx.send(result);
809        });
810
811        // While the worker is blocked inside metadata() (which loops
812        // forever), the EpochTicker thread must have started and
813        // incremented the counter. Wait for it to reach baseline + 1
814        // — this directly proves the ticker was started, not just
815        // absent throughout.
816        assert!(
817            wait_for_active_ticker_count(baseline + 1, std::time::Duration::from_secs(10)),
818            "metadata-loop constructor never started an epoch ticker \
819             (count stayed at {}, expected {} during construction)",
820            active_epoch_ticker_count(),
821            baseline + 1
822        );
823
824        // Wait for the worker to terminate with a test-level timeout.
825        // The epoch deadline is 5 seconds; 15 seconds gives ample
826        // margin while still failing promptly if epoch interruption
827        // regresses. If the worker panics before `send`, `rx` returns
828        // `Disconnected` and we re-raise the panic payload so the
829        // failure points at the actual panic site.
830        let result = match rx.recv_timeout(std::time::Duration::from_secs(15)) {
831            Ok(result) => result,
832            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
833                panic!(
834                    "metadata-loop constructor did not terminate within test timeout (15s) \
835                     — epoch interruption or worker exit logic may have regressed"
836                );
837            }
838            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
839                let payload = worker.join().expect_err(
840                    "metadata-loop constructor: rx disconnected but worker did not panic \
841                     — this should be unreachable",
842                );
843                std::panic::resume_unwind(payload);
844            }
845        };
846        assert!(result.is_err(), "metadata-loop handler must fail to load");
847
848        // Join the worker to propagate any panic that occurred after
849        // `send` (essentially unreachable, but kept for completeness).
850        worker
851            .join()
852            .expect("metadata-loop constructor thread panicked after sending result");
853
854        // After the failed load, the ticker thread must have been joined
855        // and the count must return to baseline.
856        assert!(
857            wait_for_active_ticker_count(baseline, std::time::Duration::from_secs(3)),
858            "active ticker count did not return to baseline {} after metadata-loop failure (got {})",
859            baseline,
860            active_epoch_ticker_count()
861        );
862
863        // Emit a CI-log marker so the audit report can cite the exact
864        // step that proves the test-level timeout is in place. Only
865        // printed when the test runs to completion (i.e. not skipped).
866        println!("metadata-loop constructor timeout test: PASS");
867
868        // Drop the Arc<LoadedHandlerPack> explicitly so its refcount
869        // goes to zero and any test-only state is released before the
870        // next test runs.
871        drop(loaded);
872    }
873
874    /// Source-level invariant: the test-only `ACTIVE_EPOCH_TICKERS`
875    /// static and its private `active_epoch_ticker_count` accessor are
876    /// reachable from this internal `#[cfg(test)] mod tests` (via
877    /// `use super::*`), so the lifecycle tests above can observe ticker
878    /// thread start/stop directly. This test confirms the counter is
879    /// *available to internal tests* — it is **not** a public-API
880    /// assertion. The primary public-API leak check is the external
881    /// compile-fail crate in `scripts/check_runtime_public_api.py`
882    /// (run by the `wasm-handler` CI job); the `cargo doc` + `grep`
883    /// step in the same job is an auxiliary smoke check that only
884    /// catches probes which are both `pub` and not `#[doc(hidden)]`.
885    ///
886    /// This test was previously named
887    /// `ticker_probe_is_not_in_public_api`, which was misleading
888    /// because a private `fn` reachable from `super::*` says nothing
889    /// about whether a future refactor might re-expose it as `pub`.
890    /// The renamed test now accurately describes what it checks.
891    #[test]
892    fn ticker_probe_is_available_to_internal_tests() {
893        let _guard = lifecycle_guard();
894        let baseline = active_epoch_ticker_count();
895        // The static itself is reachable from `super::*` (via the
896        // `use super::*;` at the top of `mod tests`). If the static or
897        // the accessor were `pub`, `cargo doc --features wasm --no-deps`
898        // would surface them in the public API; the dedicated CI
899        // `grep -R "active_epoch_ticker_count|ACTIVE_EPOCH_TICKERS"
900        // target/doc/rill_runtime` step must return no matches.
901        let _ = ACTIVE_EPOCH_TICKERS.load(Ordering::SeqCst);
902        assert_eq!(active_epoch_ticker_count(), baseline);
903    }
904}