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}