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}