Skip to main content

tensor_wasm_exec/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! [`TensorWasmEngine`] — a [`wasmtime::Engine`] wrapper preconfigured for TensorWasm.
5//!
6//! - Async execution (cooperative fuel via epoch-based interruption).
7//! - Custom [`MemoryCreator`](wasmtime::MemoryCreator) so linear memory is
8//!   carved from [`tensor_wasm_mem::wasm_memory::TensorWasmMemoryCreator`] (CUDA Unified
9//!   Memory on supported hosts; plain Box on others).
10//! - Epoch ticker: a background Tokio task increments the engine's epoch
11//!   counter every [`TensorWasmEngine::EPOCH_TICK`] so calls past their deadline are
12//!   interrupted promptly.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use tensor_wasm_mem::wasm_memory::TensorWasmMemoryCreator;
18use tokio::task::JoinHandle;
19use wasmtime::{
20    Config, Enabled, Engine, InstanceAllocationStrategy, PoolingAllocationConfig, Strategy,
21};
22
23/// Default epoch tick. Matches the plan's 10 ms cadence.
24const DEFAULT_EPOCH_TICK: Duration = Duration::from_millis(10);
25
26/// Explicit ceiling on the wasm operand/value stack a single call may use,
27/// in bytes (LOW/hardening finding). Wasmtime applies a default stack ceiling
28/// today, but pinning it here makes the resource contract independent of any
29/// future wasmtime default change. 1 MiB is generous for the deeply-nested
30/// recursion / large-frame guests we expect while still bounding host stack
31/// consumption per fiber. Kept STRICTLY BELOW the async fiber stack reserved
32/// by wasmtime: under `async_support` wasmtime requires
33/// `max_wasm_stack <= async_stack_size`, and the async stack also has to hold
34/// the host frames of any async host import. We do not override
35/// `async_stack_size`, so it stays at wasmtime's default (2 MiB), leaving
36/// ample headroom (2 MiB >= 1 MiB) for host frames on top of the guest stack.
37const MAX_WASM_STACK_BYTES: usize = 1_048_576;
38
39/// Approximate host-memory cost of a single wasm table entry on wasmtime
40/// (a tagged pointer plus a type-index slot). Kept in lockstep with the
41/// same constant in
42/// [`TensorWasmResourceLimiter::table_growing`](crate::executor::TensorWasmResourceLimiter)
43/// so the pooling allocator's `table_elements` ceiling is derived from the
44/// same per-instance budget the limiter enforces on the `UnifiedBuffer`
45/// path (MED finding — the two backends must agree on the table budget).
46pub(crate) const TABLE_ENTRY_BYTES: u64 = 16;
47
48/// Selects the linear-memory backing strategy for the engine.
49///
50/// The two modes are mutually exclusive at the Wasmtime level:
51/// `with_host_memory` (used by [`MemoryBackend::UnifiedBuffer`]) cannot coexist
52/// with the pooling allocator (required by [`MemoryBackend::PoolingMpk`]).
53/// Operators pick the mode that fits their workload.
54#[derive(Debug, Clone, Default)]
55pub enum MemoryBackend {
56    /// Host-provided UnifiedBuffer-backed linear memory via `with_host_memory`.
57    /// Required for the GPU integration path (kernels read/write the same
58    /// allocation the Wasm guest sees). DOES NOT support MPK — Wasmtime's
59    /// pooling+MPK machinery is mutually exclusive with custom MemoryCreator.
60    #[default]
61    UnifiedBuffer,
62    /// Wasmtime's pooling allocator with MPK (memory protection keys).
63    /// Trades the GPU integration path for intra-process Wasm isolation via
64    /// CPU PKU. Suitable for CPU-only or batch-GPU workloads where kernel
65    /// launches don't share memory with Wasm at byte level.
66    PoolingMpk {
67        /// Maximum total memories tracked by the pooling allocator.
68        max_memories: u32,
69        /// Bytes per memory slot.
70        memory_bytes: usize,
71    },
72}
73
74/// Configuration knobs for the engine.
75#[derive(Debug, Clone)]
76pub struct EngineConfig {
77    /// Maximum allocated linear memory per instance (bytes).
78    pub max_memory_bytes: usize,
79    /// Period between background `increment_epoch` ticks.
80    pub epoch_tick: Duration,
81    /// Compilation strategy. `Strategy::Cranelift` for production.
82    pub strategy: Strategy,
83    /// Enable Wasm component model.
84    pub component_model: bool,
85    /// Linear-memory backing strategy. See [`MemoryBackend`] for the
86    /// UnifiedBuffer vs PoolingMpk trade-off.
87    pub backend: MemoryBackend,
88    /// Maximum number of compiled-module cache entries retained per
89    /// executor before LRU eviction kicks in. Closes exec S-5: an
90    /// unbounded `DashMap<digest, Module>` lets a misbehaving tenant
91    /// pin arbitrarily many compiled modules (each multi-MiB of host
92    /// RAM) by submitting unique wasm bytes in a loop. 1024 is enough
93    /// to hold the working set of a typical multi-tenant deployment
94    /// while bounding the worst case at ~a few GiB of compiled-code
95    /// pages.
96    pub max_module_cache_entries: usize,
97    /// Hard upper bound on the number of concurrently-live instances
98    /// the executor will admit. Closes exec S-10: an unbounded
99    /// `DashMap<InstanceId, ...>` lets a tenant spawn instances in a
100    /// loop until the host OOMs. `None` disables the cap (useful for
101    /// tests / single-tenant deployments); production callers should
102    /// keep the default ceiling. When the limit is hit `spawn_instance`
103    /// returns [`crate::executor::ExecError::CapacityExhausted`].
104    pub max_instances: Option<usize>,
105    /// Pre-compile cap on the byte length of a submitted Wasm module.
106    /// Bytes above this are rejected with
107    /// [`crate::executor::ExecError::ModuleTooLarge`] *before*
108    /// `Module::from_binary` runs, preventing pathological code
109    /// sections from forcing Cranelift to burn arbitrary CPU on
110    /// adversarial input. Default is
111    /// [`crate::executor::MAX_MODULE_BYTES`] (64 MiB); embedders may
112    /// tighten further but the constant is the documented floor.
113    pub max_module_bytes: usize,
114    /// Upper bound on the number of `Module::from_binary` (Cranelift)
115    /// compiles allowed to run concurrently on the Tokio blocking pool.
116    ///
117    /// Each compile is offloaded via [`tokio::task::spawn_blocking`]; the
118    /// blocking pool is a shared process resource (default 512 threads).
119    /// Without a bound, an adversary submitting a stream of *unique* large
120    /// modules — each a cache miss, each a multi-hundred-millisecond
121    /// Cranelift run — can saturate the pool and starve every other
122    /// blocking operation in the process. This cap is independent of
123    /// [`Self::max_instances`] (which bounds live instances, not in-flight
124    /// compiles) and is enforced by a per-executor
125    /// [`tokio::sync::Semaphore`]. `None` selects a default derived from
126    /// [`std::thread::available_parallelism`] (floored at 1) at executor
127    /// construction time.
128    pub max_concurrent_compiles: Option<usize>,
129    /// Opt-in: activate the auto-offload Wasm rewrite on the spawn path.
130    ///
131    /// When `false` (the default) the
132    /// [`auto_offload::analyse`](crate::auto_offload::analyse) pass remains
133    /// consultation-only — it emits `tracing` verdicts but Wasmtime's
134    /// Cranelift output is never replaced, exactly matching the historical
135    /// behaviour. When `true`,
136    /// [`TensorWasmExecutor::spawn_instance`](crate::executor::TensorWasmExecutor::spawn_instance)
137    /// runs the analyser and, if any function is flagged for offload, feeds
138    /// the module through
139    /// [`tensor_wasm_jit::rewrite::rewrite_wasm`] to produce a
140    /// trampoline-augmented module which is instantiated *instead of* the
141    /// original. The original bytes are always retained as a fallback: any
142    /// analysis or rewrite failure (or a rewrite that swaps nothing) is
143    /// logged and the spawn proceeds with the unmodified module, so enabling
144    /// this flag can never fail a spawn that would otherwise have succeeded.
145    ///
146    /// Requires a [`KernelCache`](tensor_wasm_jit::cache::KernelCache)
147    /// attached to the executor via
148    /// [`TensorWasmExecutor::with_jit_cache`](crate::executor::TensorWasmExecutor::with_jit_cache)
149    /// for the rewritten guest's `tensor-wasm:jit/host` imports to link;
150    /// without one, the rewrite is skipped (the trampoline imports would be
151    /// unlinkable) and the original module is used.
152    pub auto_offload: bool,
153    /// Detector thresholds the auto-offload activation path uses to decide
154    /// which function bodies are offload candidates.
155    ///
156    /// `None` (the default) selects
157    /// [`tensor_wasm_jit::detector::DetectorConfig::default`] — the
158    /// production-conservative thresholds. Embedders (and tests) that want a
159    /// more aggressive offload policy can supply tuned thresholds here; the
160    /// same config is threaded into BOTH the consultation pass
161    /// ([`auto_offload::analyse_with_config`](crate::auto_offload::analyse_with_config))
162    /// and the
163    /// [`tensor_wasm_jit::rewrite::RewriteOptions::detector`] used for the
164    /// rewrite, so the consultation verdict and the rewrite always agree on
165    /// which functions to swap. Ignored entirely when
166    /// [`Self::auto_offload`] is `false`.
167    pub auto_offload_detector: Option<tensor_wasm_jit::detector::DetectorConfig>,
168    /// Optional per-tenant cap on the number of concurrently-live instances
169    /// a single [`TenantId`](tensor_wasm_core::types::TenantId) may hold.
170    ///
171    /// Complements the engine-wide [`Self::max_instances`] ceiling with a
172    /// fairness bound: one tenant spawning in a loop cannot starve every
173    /// other tenant of the shared instance budget. Enforced in the same
174    /// admission path as `max_instances` (keyed by the spawning
175    /// [`SpawnConfig::tenant_id`](crate::executor::SpawnConfig::tenant_id)),
176    /// with the same charge-before-compile / roll-back-on-failure
177    /// accounting. When a tenant exceeds its cap the spawn is refused with
178    /// [`ExecError::CapacityExhausted`](crate::executor::ExecError::CapacityExhausted)
179    /// (reused for the per-tenant case to keep the cross-crate error mapping
180    /// non-breaking; the offending tenant is logged server-side) carrying the
181    /// tenant's own count and per-tenant `limit`, without affecting any other
182    /// tenant. `None` (the default) disables the per-tenant cap — only the
183    /// engine-wide `max_instances` applies.
184    pub max_instances_per_tenant: Option<usize>,
185}
186
187impl EngineConfig {
188    /// The effective per-instance linear-memory cap, in bytes, reconciling
189    /// the engine-wide [`Self::max_memory_bytes`] ceiling with the pooling
190    /// allocator's own per-slot byte size when
191    /// [`MemoryBackend::PoolingMpk`] is selected (MED finding).
192    ///
193    /// On the [`MemoryBackend::UnifiedBuffer`] path this is simply
194    /// `max_memory_bytes`. On the pooling path the physical slot size
195    /// (`memory_bytes`) is an independent hard ceiling the allocator
196    /// enforces at instantiation; a module larger than that slot would fail
197    /// to instantiate regardless of `max_memory_bytes`. Taking the minimum
198    /// of the two means the executor's pre-instantiation module check
199    /// ([`check_module_memory_within_cap`](crate::executor)) rejects an
200    /// oversized module with the typed
201    /// [`ExecError::ModuleMemoryTooLarge`](crate::executor::ExecError::ModuleMemoryTooLarge)
202    /// *before* the pooling allocator can surface an opaque
203    /// `ExecError::Wasmtime`, and the per-store
204    /// [`TensorWasmResourceLimiter`](crate::executor::TensorWasmResourceLimiter)
205    /// caps `memory.grow` against the same reconciled value.
206    pub fn effective_memory_cap(&self) -> usize {
207        match self.backend {
208            MemoryBackend::UnifiedBuffer => self.max_memory_bytes,
209            MemoryBackend::PoolingMpk { memory_bytes, .. } => {
210                self.max_memory_bytes.min(memory_bytes)
211            }
212        }
213    }
214}
215
216impl Default for EngineConfig {
217    fn default() -> Self {
218        Self {
219            max_memory_bytes: 256 * 1024 * 1024,
220            epoch_tick: DEFAULT_EPOCH_TICK,
221            strategy: Strategy::Cranelift,
222            component_model: true,
223            backend: MemoryBackend::default(),
224            max_module_cache_entries: 1024,
225            max_instances: Some(10_000),
226            max_module_bytes: crate::executor::MAX_MODULE_BYTES,
227            // None => derive from available_parallelism at executor
228            // construction. Keeps the default tied to the host's core
229            // count without pulling in a `num_cpus` dependency.
230            max_concurrent_compiles: None,
231            // Consultation-only by default: the analyser still runs and
232            // emits verdicts, but Wasmtime's Cranelift output is not
233            // replaced unless an embedder opts in.
234            auto_offload: false,
235            // None => default detector thresholds when the swap is enabled.
236            auto_offload_detector: None,
237            // No per-tenant fairness cap by default — only the engine-wide
238            // `max_instances` ceiling applies.
239            max_instances_per_tenant: None,
240        }
241    }
242}
243
244/// A configured [`wasmtime::Engine`] plus the background epoch ticker that
245/// drives interruption.
246pub struct TensorWasmEngine {
247    engine: Engine,
248    ticker_handle: Option<JoinHandle<()>>,
249    config: EngineConfig,
250}
251
252impl TensorWasmEngine {
253    /// Default epoch tick interval.
254    pub const EPOCH_TICK: Duration = DEFAULT_EPOCH_TICK;
255
256    /// Construct an engine with default configuration.
257    pub fn new() -> Result<Self, wasmtime::Error> {
258        Self::with_config(EngineConfig::default())
259    }
260
261    /// Construct an engine with explicit configuration.
262    pub fn with_config(cfg: EngineConfig) -> Result<Self, wasmtime::Error> {
263        let mut wt_cfg = Config::new();
264        // wasmtime 45: async support is enabled by the `async` cargo feature
265        // and the `*_async` call sites; `Config::async_support` is deprecated
266        // and a no-op, so it is no longer set here.
267        wt_cfg.epoch_interruption(true);
268        wt_cfg.consume_fuel(false);
269        wt_cfg.wasm_component_model(cfg.component_model);
270        wt_cfg.strategy(cfg.strategy);
271
272        // ─── Wasm stack ceiling (LOW/hardening pin) ─────────────────────────
273        //
274        // Pin the per-call wasm stack limit EXPLICITLY rather than relying on
275        // wasmtime's built-in default, so a future wasmtime bump that widens
276        // (or narrows) that default cannot silently change our resource
277        // contract — the same reasoning that motivates the proposal pins
278        // below. 1 MiB bounds host stack growth per fiber while comfortably
279        // accommodating deeply-nested guests. Under `async_support` wasmtime
280        // requires this to be <= `async_stack_size` (default 2 MiB, which we
281        // do not override), with the difference reserved for host frames on
282        // the fiber; 1 MiB leaves 1 MiB of headroom, so the invariant holds.
283        wt_cfg.max_wasm_stack(MAX_WASM_STACK_BYTES);
284
285        // ─── Wasm proposal deny-list (security pin) ─────────────────────────
286        //
287        // These flags are pinned because:
288        //   (a) the hardened multi-tenant trust model of this crate depends
289        //       on them — enabling a proposal we have not audited (threads,
290        //       memory64, multi-memory, relaxed-simd, tail-call, GC, typed
291        //       function references) would widen the sandbox attack surface
292        //       and may invalidate isolation assumptions (e.g. `wasm_threads`
293        //       interacts with our pooling/MPK backend in non-obvious ways);
294        //   (b) a future `wasmtime` minor/patch bump must not silently change
295        //       behaviour. If wasmtime flips a default upstream, we want the
296        //       guest contract to remain identical until we explicitly opt in.
297        //
298        // The positive flags below are the proposals this codebase *does*
299        // consume; pinning them to `true` defends against the symmetric
300        // failure mode (a future bump silently disabling something we rely
301        // on).
302        // Proposal flags exposed by the workspace's wasmtime feature set
303        // (`async`, `cranelift`, `component-model`, `runtime`). The list is
304        // intentionally narrow — additional flags (`wasm_threads`,
305        // `wasm_gc`, `wasm_function_references`, `wasm_reference_types`) are
306        // gated behind feature flags we do NOT enable in this workspace, so
307        // the corresponding proposals are already compiled out of the engine
308        // and cannot be activated by config alone. If those wasmtime
309        // features ever get pulled in, mirror them here with `_(false)`.
310        wt_cfg.wasm_memory64(false);
311        wt_cfg.wasm_multi_memory(false);
312        wt_cfg.wasm_relaxed_simd(false);
313        wt_cfg.wasm_tail_call(false);
314        // Defense-in-depth (L-4): the remaining audited-out proposals would be
315        // pinned `false` here too, but on wasmtime 45 their `Config` setters do
316        // not exist in this build — they are `#[cfg]`-gated behind crate
317        // features we deliberately do NOT enable, so the proposals are fully
318        // compiled out of the engine and config cannot turn them on:
319        //   - `wasm_reference_types(false)`   -> gated `feature = "gc"`     (off)
320        //   - `wasm_function_references(false)`-> gated `feature = "gc"`     (off)
321        //   - `wasm_gc(false)`                -> gated `feature = "gc"`     (off)
322        //   - `wasm_threads(false)`           -> gated `feature = "threads"`(off)
323        // If a future change pulls in the `gc` and/or `threads` wasmtime
324        // features, un-comment the corresponding setters below so the deny-list
325        // stays explicit rather than relying on the proposals being absent:
326        //   wt_cfg.wasm_reference_types(false);
327        //   wt_cfg.wasm_function_references(false);
328        //   wt_cfg.wasm_gc(false);
329        //   wt_cfg.wasm_threads(false);
330        // Explicitly KEEP the proposals we depend on, so a wasmtime bump
331        // cannot silently flip them:
332        wt_cfg.wasm_simd(true);
333        wt_cfg.wasm_bulk_memory(true);
334        wt_cfg.wasm_multi_value(true);
335
336        match cfg.backend {
337            MemoryBackend::UnifiedBuffer => {
338                let memory_creator = Arc::new(TensorWasmMemoryCreator::default());
339                wt_cfg.with_host_memory(memory_creator);
340                wt_cfg.guard_before_linear_memory(false);
341                wt_cfg.memory_init_cow(false);
342                // wasmtime 45 consolidated the static/dynamic memory tuning
343                // knobs: `static_memory_maximum_size` -> `memory_reservation`
344                // and `dynamic_memory_guard_size` -> `memory_guard_size`.
345                // Both are 0 here because the host `MemoryCreator` above owns
346                // the allocation (no wasmtime-side reservation or guard page).
347                wt_cfg.memory_reservation(0);
348                wt_cfg.memory_guard_size(0);
349            }
350            MemoryBackend::PoolingMpk {
351                max_memories,
352                memory_bytes,
353            } => {
354                // Pooling owns the memory backing — do NOT install a host
355                // memory creator, and leave Wasmtime's default guard sizes
356                // in place (the pooling allocator depends on them).
357                //
358                // Reconcile the pooling per-slot byte size with the engine's
359                // per-instance cap (MED finding). Two independent dials —
360                // `max_memory_bytes` (the `ResourceLimiter` ceiling and the
361                // module pre-instantiation check) and the pooling
362                // `memory_bytes` (the allocator's physical slot size) — were
363                // not reconciled: a module that passed the
364                // `max_memory_bytes` check could still exceed the pooling
365                // slot and fail instantiation with an opaque allocator error.
366                // The effective per-instance memory cap is the *minimum* of
367                // the two, and the pooling slot is sized to that value so the
368                // allocator never has to refuse a module the cap check
369                // already admitted. `effective_memory_cap()` computes the
370                // same minimum that `check_module_memory_within_cap` validates
371                // against in the executor.
372                let effective = cfg.max_memory_bytes.min(memory_bytes);
373                let mut pooling = PoolingAllocationConfig::default();
374                pooling.total_memories(max_memories);
375                pooling.max_memory_size(effective);
376                // Derive the table limits from the same reconciled
377                // per-instance budget the executor's `ResourceLimiter` uses
378                // (`effective / TABLE_ENTRY_BYTES`, matching
379                // `TensorWasmResourceLimiter::table_growing`), so a module
380                // admitted by the limiter is not then refused by the pooling
381                // allocator's own table ceiling. One table slot per memory
382                // slot. The pooling allocator caps `table_elements` at a
383                // `u32`, so saturate on the cast.
384                pooling.total_tables(max_memories);
385                let table_elems: usize = (effective as u64 / TABLE_ENTRY_BYTES)
386                    .try_into()
387                    .unwrap_or(usize::MAX);
388                pooling.table_elements(table_elems);
389                pooling.memory_protection_keys(Enabled::Auto);
390                wt_cfg.allocation_strategy(InstanceAllocationStrategy::Pooling(pooling));
391            }
392        }
393
394        let engine = Engine::new(&wt_cfg)?;
395        let mut this = Self {
396            engine,
397            ticker_handle: None,
398            config: cfg,
399        };
400        // exec S-4: auto-spawn the epoch ticker if we're already inside a
401        // Tokio runtime. Without the ticker, any `SpawnConfig::with_deadline`
402        // becomes silently inert — deadlines cannot fire. Operators who
403        // construct the engine OUTSIDE a runtime (synchronous startup) still
404        // have to call `spawn_epoch_ticker()` after `Runtime::block_on` /
405        // `Runtime::new()`; the `spawn_instance` path also emits a loud
406        // `tracing::error!` (see executor.rs) the first time a deadline is
407        // requested with no ticker running, so the silent-failure mode is
408        // closed defence-in-depth.
409        if tokio::runtime::Handle::try_current().is_ok() {
410            this.spawn_epoch_ticker();
411        }
412        Ok(this)
413    }
414
415    /// Borrow the underlying wasmtime Engine. Cheap (it's `Arc`-shaped internally).
416    pub fn inner(&self) -> &Engine {
417        &self.engine
418    }
419
420    /// Borrow the engine config used at construction.
421    pub fn config(&self) -> &EngineConfig {
422        &self.config
423    }
424
425    /// Spawn a background Tokio task that periodically increments the engine
426    /// epoch counter. Must be called from inside a Tokio runtime.
427    ///
428    /// Idempotent: if a ticker is already running this is a no-op.
429    pub fn spawn_epoch_ticker(&mut self) {
430        if self.ticker_handle.is_some() {
431            return;
432        }
433        let engine = self.engine.clone();
434        let tick = self.config.epoch_tick;
435        let handle = tokio::spawn(async move {
436            loop {
437                tokio::time::sleep(tick).await;
438                engine.increment_epoch();
439                // No per-tick trace event: at the default 10 ms cadence this
440                // floods structured-logging backends. Operators wanting to
441                // verify the ticker is alive should look at engine span
442                // counts or use `TensorWasmEngine::tick` from a probe.
443            }
444        });
445        self.ticker_handle = Some(handle);
446    }
447
448    /// Stop the epoch ticker if one is running.
449    pub fn stop_epoch_ticker(&mut self) {
450        if let Some(h) = self.ticker_handle.take() {
451            h.abort();
452        }
453    }
454
455    /// True if [`spawn_epoch_ticker`](Self::spawn_epoch_ticker) has been called
456    /// on this engine and the ticker has not been
457    /// [`stop_epoch_ticker`](Self::stop_epoch_ticker)'d.
458    ///
459    /// Used by [`TensorWasmExecutor`](crate::executor::TensorWasmExecutor) to
460    /// emit a one-shot operator warning the first time an instance is spawned
461    /// on an engine whose ticker is not running (deadlines are otherwise
462    /// silently inert).
463    pub fn is_epoch_ticker_running(&self) -> bool {
464        self.ticker_handle
465            .as_ref()
466            .is_some_and(|h| !h.is_finished())
467    }
468
469    /// Increment the epoch once manually. Useful for tests that do not want
470    /// to wait for the background ticker.
471    pub fn tick(&self) {
472        self.engine.increment_epoch();
473    }
474}
475
476impl Drop for TensorWasmEngine {
477    fn drop(&mut self) {
478        self.stop_epoch_ticker();
479    }
480}
481
482impl Default for TensorWasmEngine {
483    fn default() -> Self {
484        Self::new().expect("default TensorWasmEngine construction")
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[tokio::test]
493    async fn engine_constructs() {
494        let _engine = TensorWasmEngine::new().expect("construct");
495    }
496
497    #[tokio::test]
498    async fn ticker_is_idempotent() {
499        let mut engine = TensorWasmEngine::new().unwrap();
500        engine.spawn_epoch_ticker();
501        engine.spawn_epoch_ticker();
502        engine.stop_epoch_ticker();
503        engine.stop_epoch_ticker();
504    }
505
506    #[tokio::test]
507    async fn manual_tick() {
508        let engine = TensorWasmEngine::new().unwrap();
509        engine.tick();
510        engine.tick();
511        // No assertions on the engine's internal epoch counter — wasmtime does
512        // not expose it. We only verify the call doesn't panic.
513    }
514
515    #[test]
516    fn default_config_values() {
517        let c = EngineConfig::default();
518        assert_eq!(c.max_memory_bytes, 256 * 1024 * 1024);
519        assert_eq!(c.epoch_tick, Duration::from_millis(10));
520        assert!(c.component_model);
521        assert!(matches!(c.backend, MemoryBackend::UnifiedBuffer));
522    }
523
524    #[test]
525    fn effective_memory_cap_unified_is_max_memory_bytes() {
526        let cfg = EngineConfig {
527            max_memory_bytes: 128 * 1024 * 1024,
528            backend: MemoryBackend::UnifiedBuffer,
529            ..EngineConfig::default()
530        };
531        assert_eq!(cfg.effective_memory_cap(), 128 * 1024 * 1024);
532    }
533
534    #[test]
535    fn effective_memory_cap_pooling_takes_minimum() {
536        // Pooling slot smaller than the engine cap → slot wins.
537        let cfg = EngineConfig {
538            max_memory_bytes: 256 * 1024 * 1024,
539            backend: MemoryBackend::PoolingMpk {
540                max_memories: 8,
541                memory_bytes: 64 * 1024 * 1024,
542            },
543            ..EngineConfig::default()
544        };
545        assert_eq!(cfg.effective_memory_cap(), 64 * 1024 * 1024);
546
547        // Engine cap smaller than the pooling slot → engine cap wins.
548        let cfg = EngineConfig {
549            max_memory_bytes: 16 * 1024 * 1024,
550            backend: MemoryBackend::PoolingMpk {
551                max_memories: 8,
552                memory_bytes: 64 * 1024 * 1024,
553            },
554            ..EngineConfig::default()
555        };
556        assert_eq!(cfg.effective_memory_cap(), 16 * 1024 * 1024);
557    }
558
559    #[tokio::test]
560    async fn engine_constructs_with_unified_backend() {
561        let cfg = EngineConfig {
562            backend: MemoryBackend::UnifiedBuffer,
563            ..EngineConfig::default()
564        };
565        let engine = TensorWasmEngine::with_config(cfg);
566        assert!(
567            engine.is_ok(),
568            "engine should construct: {:?}",
569            engine.err()
570        );
571    }
572
573    #[tokio::test]
574    async fn engine_constructs_with_pooling_mpk_backend() {
575        let cfg = EngineConfig {
576            backend: MemoryBackend::PoolingMpk {
577                max_memories: 32,
578                memory_bytes: 64 * 1024,
579            },
580            ..EngineConfig::default()
581        };
582        let engine = TensorWasmEngine::with_config(cfg);
583        assert!(
584            engine.is_ok(),
585            "engine should construct: {:?}",
586            engine.err()
587        );
588    }
589}