Skip to main content

tensor_wasm_exec/
instance.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Per-instance state used by [`TensorWasmExecutor`](crate::executor::TensorWasmExecutor).
5//!
6//! Each running Wasm instance has a [`TensorWasmInstance`] which owns:
7//! - the wasmtime `Store<InstanceState>` driving execution,
8//! - the wasmtime `Instance` itself,
9//! - identity (`TenantId`, `InstanceId`),
10//! - per-instance deadlines and metadata used by metrics and tracing,
11//! - the per-instance [`TensorWasmResourceLimiter`]
12//!   that caps linear-memory growth.
13//!
14//! Instances are typically held inside an `Arc<Mutex<TensorWasmInstance>>` so the
15//! executor can drive their lifecycle from a Tokio task while the API layer
16//! invokes exported functions.
17
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::{Duration, Instant};
20
21use tensor_wasm_core::types::{InstanceId, TenantId};
22use tensor_wasm_wasi_gpu::scheduler::SchedulerContext;
23use tensor_wasm_wasi_gpu::streaming::{HasInput, HasStreaming, InputContext, StreamingContext};
24
25use crate::executor::TensorWasmResourceLimiter;
26use crate::jit_dispatch::{ArenaState, JitArenaProvider};
27
28/// Side-channel data attached to each wasmtime `Store`.
29///
30/// Wasmtime gives every Store a `T` payload accessible from host functions —
31/// we store identity, deadlines, and resource counters here so WASI-CUDA
32/// host functions (S8) can access them via `caller.data()`.
33#[derive(Debug)]
34pub struct InstanceState {
35    /// Owning tenant.
36    pub tenant_id: TenantId,
37    /// Unique instance identifier.
38    pub instance_id: InstanceId,
39    /// Walltime the instance was created.
40    pub created_at: Instant,
41    /// Soft deadline (epoch-driven) for the **current** call.
42    ///
43    /// `None` means "no host-imposed deadline". This is re-armed at the start
44    /// of each [`TensorWasmExecutor::call_export`](crate::executor::TensorWasmExecutor::call_export)
45    /// from [`InstanceState::deadline_duration`], so back-to-back calls each
46    /// get a fresh wall-clock window instead of inheriting the elapsed time
47    /// from a previous call.
48    pub deadline: Option<Instant>,
49    /// Per-call deadline as a [`Duration`], retained so the executor can
50    /// re-arm the absolute [`InstanceState::deadline`] (and the wasmtime
51    /// epoch deadline) at the start of each call. `None` for instances
52    /// spawned without a deadline.
53    pub deadline_duration: Option<Duration>,
54    /// Absolute wall-clock instant past which the cooperative epoch
55    /// callback must **trap** (terminate the guest) rather than yield.
56    ///
57    /// This is the source of truth for the HARD deadline guarantee under
58    /// the cooperative-yield scheme (see
59    /// [`crate::executor::TensorWasmExecutor`]'s epoch callback). The
60    /// store's epoch deadline is configured with
61    /// [`wasmtime::Store::epoch_deadline_callback`] in async-yield mode:
62    /// every time the epoch deadline trips, the callback compares
63    /// `Instant::now()` against this instant. If the instant has NOT
64    /// passed, the callback returns [`wasmtime::UpdateDeadline::Yield`],
65    /// so the guest yields `Pending` to the async runtime (making a
66    /// compute-bound guest cancellable on future-drop) and the epoch
67    /// re-arms for another cooperative window. Once this instant HAS
68    /// passed, the callback returns an error, which wasmtime turns into
69    /// a trap — preserving the exact trap-on-deadline behaviour the
70    /// executor relied on under the old `set_epoch_deadline`-trap scheme.
71    ///
72    /// Distinct from [`Self::deadline`]: `deadline` is the per-call soft
73    /// budget consulted by the scheduler/back-pressure surface and by the
74    /// timeout *classification* in `call_export_with_args`, whereas
75    /// `hard_deadline` is what the epoch callback enforces. The executor
76    /// keeps them aligned — for a call with a configured deadline both are
77    /// `now + d`; during instantiation `hard_deadline` is additionally
78    /// clamped by [`crate::executor::MAX_START_FN_DURATION`] so a runaway
79    /// `start` function still traps even on a deadline-less spawn.
80    /// `None` means "no hard cap" — the callback yields cooperatively
81    /// forever (used only if no cap of any kind applies).
82    pub hard_deadline: Option<Instant>,
83    /// Total kernel dispatches issued by this instance (cumulative).
84    pub kernel_dispatches: AtomicU64,
85    /// Total bytes of GPU memory this instance has allocated.
86    pub gpu_bytes_allocated: AtomicU64,
87    /// Per-instance linear-memory limiter. Mirrors the engine's
88    /// `max_memory_bytes`; wasmtime invokes it from `memory.grow`.
89    ///
90    /// Crate-private: external code must not mutate the per-instance
91    /// limiter mid-flight (doing so would let a host import widen its
92    /// own memory cap inside a host call, defeating the engine cap).
93    /// Construction routes through [`Self::with_memory_limit`] only.
94    pub(crate) limiter: TensorWasmResourceLimiter,
95    /// Per-instance bump arena backing the JIT `alloc`/`free`/`dispatch`
96    /// imports. Living in the per-store payload (rather than captured in
97    /// the linker closures) is what keeps two tenants instantiated from
98    /// the same [`wasmtime::Linker`] from polluting each other's bump
99    /// cursor and LIFO stack — see [`JitArenaProvider`].
100    pub(crate) jit_arena: ArenaState,
101    /// Per-instance cooperative-scheduler context backing the
102    /// `wasi:scheduler/host@0.1.0` host functions (roadmap feature #4).
103    ///
104    /// Re-armed at the start of each [`crate::executor::TensorWasmExecutor::call_export`]
105    /// alongside [`Self::deadline`] so back-to-back calls each get a
106    /// fresh `started_at` instant — without this the second call would
107    /// observe a deadline-elapsed reading immediately because the
108    /// elapsed wall-clock from the first call would still be charged.
109    ///
110    /// On spawns without a configured deadline, this is constructed
111    /// via [`SchedulerContext::unbounded`] and every `yield()` returns
112    /// `YIELD_CODE_CONTINUE`.
113    pub(crate) scheduler: SchedulerContext,
114    /// Per-instance streaming context backing the
115    /// `wasi:tensor/host@0.1.0` `emit-chunk` / `flush` host functions
116    /// (roadmap feature #2). Constructed via
117    /// [`StreamingContext::disabled`] for spawns the gateway did not
118    /// opt into the streaming response path — every `emit-chunk` then
119    /// returns the documented `-1` code immediately.
120    ///
121    /// Set by [`crate::executor::SpawnConfig::with_streaming`] at
122    /// spawn time. v0.4 wires this through to the
123    /// `POST /functions/{id}/invoke-stream` route — see
124    /// `docs/STREAMING.md`.
125    pub(crate) streaming: StreamingContext,
126    /// Per-instance guest-input context backing the
127    /// `wasi:tensor/host@0.1.0` `input-len` / `read-input` host functions
128    /// (the pull-model input channel). Constructed via
129    /// [`InputContext::empty`] by default — `input-len` then returns `0`
130    /// and `read-input` copies nothing.
131    ///
132    /// Set by [`crate::executor::SpawnConfig::with_input`] at spawn time.
133    /// The OpenAI completions shim stages the assembled prompt bytes here
134    /// so the guest can pull them via `wasi:tensor/host.read-input`.
135    pub(crate) input: InputContext,
136}
137
138impl InstanceState {
139    /// Construct a fresh state with `created_at = Instant::now()`.
140    ///
141    /// The limiter is initialised with `usize::MAX` (no enforcement);
142    /// callers that want enforcement should chain
143    /// [`InstanceState::with_memory_limit`] (the `limiter` field is
144    /// `pub(crate)` so external code cannot widen it post-construction).
145    pub fn new(tenant_id: TenantId, instance_id: InstanceId) -> Self {
146        Self {
147            tenant_id,
148            instance_id,
149            created_at: Instant::now(),
150            deadline: None,
151            deadline_duration: None,
152            hard_deadline: None,
153            kernel_dispatches: AtomicU64::new(0),
154            gpu_bytes_allocated: AtomicU64::new(0),
155            limiter: TensorWasmResourceLimiter::new(usize::MAX),
156            jit_arena: ArenaState::default(),
157            // No deadline configured by default; spawns with a
158            // SpawnConfig::deadline overwrite this via
159            // `with_deadline_duration` so the SchedulerContext budget
160            // matches the wasmtime epoch deadline. Constructing the
161            // unbounded shape here means a guest that imports
162            // `wasi:scheduler/host` always gets a working surface
163            // — `yield()` is a no-op CONTINUE and
164            // `deadline-remaining-ms` returns u32::MAX.
165            scheduler: SchedulerContext::unbounded(),
166            // Streaming disabled by default; spawns through
167            // `/invoke-stream` install a real channel-backed context
168            // via `SpawnConfig::with_streaming`. Guests that import
169            // `wasi:tensor/host` and run under the non-streaming
170            // `/invoke` route see every `emit-chunk` return `-1`.
171            streaming: StreamingContext::disabled(),
172            // No input staged by default; spawns that stage a prompt
173            // install a populated context via `SpawnConfig::with_input`.
174            // Guests calling `wasi:tensor/host.input-len` then see `0`.
175            input: InputContext::empty(),
176        }
177    }
178
179    /// Install a [`StreamingContext`] on this state; returns `self`
180    /// for builder-style chaining.
181    ///
182    /// Called from [`crate::executor::TensorWasmExecutor::spawn_instance`]
183    /// when [`crate::executor::SpawnConfig::streaming`] is set so the
184    /// `wasi:tensor/host.emit-chunk` host function reaches a live
185    /// downstream receiver. Spawns without a streaming context retain
186    /// the default [`StreamingContext::disabled`] shape.
187    pub fn with_streaming(mut self, streaming: StreamingContext) -> Self {
188        self.streaming = streaming;
189        self
190    }
191
192    /// Borrow the per-instance streaming context. Used by the
193    /// `wasi:tensor/host` linker registration to plumb the
194    /// per-instance state into the `emit-chunk` and `flush` host
195    /// functions.
196    pub fn streaming(&self) -> &StreamingContext {
197        &self.streaming
198    }
199
200    /// Install an [`InputContext`] on this state; returns `self` for
201    /// builder-style chaining.
202    ///
203    /// Called from [`crate::executor::TensorWasmExecutor`]'s spawn path
204    /// when [`crate::executor::SpawnConfig::input`] is non-empty so the
205    /// `wasi:tensor/host.read-input` host function reaches the staged
206    /// bytes. Spawns without input retain the default
207    /// [`InputContext::empty`] shape.
208    pub fn with_input(mut self, input: InputContext) -> Self {
209        self.input = input;
210        self
211    }
212
213    /// Borrow the per-instance input context. Used by the
214    /// `wasi:tensor/host` linker registration to plumb the per-instance
215    /// staged bytes into the `input-len` and `read-input` host functions.
216    pub fn input(&self) -> &InputContext {
217        &self.input
218    }
219
220    /// Set the per-instance linear-memory cap (in bytes). Returns `self`
221    /// for builder-style use.
222    pub fn with_memory_limit(mut self, max_memory_bytes: usize) -> Self {
223        self.limiter = TensorWasmResourceLimiter::new(max_memory_bytes);
224        self
225    }
226
227    /// Set a deadline; returns `self` for builder-style use.
228    pub fn with_deadline(mut self, deadline: Instant) -> Self {
229        self.deadline = Some(deadline);
230        self
231    }
232
233    /// Set the absolute instant at which the cooperative epoch callback
234    /// must trap (the HARD deadline). Returns `self` for builder-style use.
235    /// See [`Self::hard_deadline`].
236    pub(crate) fn with_hard_deadline(mut self, at: Instant) -> Self {
237        self.hard_deadline = Some(at);
238        self
239    }
240
241    /// True iff a hard deadline is configured and `Instant::now()` has
242    /// reached or passed it. Consulted by the executor's epoch-deadline
243    /// callback to decide between a cooperative yield and a hard trap.
244    pub(crate) fn hard_deadline_elapsed(&self) -> bool {
245        match self.hard_deadline {
246            Some(at) => Instant::now() >= at,
247            None => false,
248        }
249    }
250
251    /// Record the per-call deadline duration so subsequent calls can re-arm
252    /// the wall-clock deadline (and matching wasmtime epoch ticks) instead
253    /// of inheriting the elapsed window from spawn time.
254    ///
255    /// Also seeds the cooperative-scheduler context with the same
256    /// budget (clamped to `u32::MAX` ms ≈ 49 days, which is well above
257    /// any plausible production deadline) so guests calling
258    /// `wasi:scheduler/host.yield()` observe a non-zero return code
259    /// when the deadline is approaching.
260    ///
261    /// T36: also seeds the scheduler's absolute `Instant` deadline
262    /// (`bp_deadline_instant`) to `[Self::deadline]`. With both fields
263    /// installed, the guest's `yield()` verdict is derived from the
264    /// same `Instant` that the back-pressure semaphore consults — so
265    /// the cooperative path and the resource-admission path agree
266    /// when the deadline trips.
267    pub fn with_deadline_duration(mut self, d: Duration) -> Self {
268        self.deadline_duration = Some(d);
269        // Clamp to u32::MAX; a deadline larger than that is in
270        // practice "unbounded" — the epoch interrupt would never fire
271        // within u32::MAX ms either, so the cooperative path can
272        // honestly report u32::MAX too.
273        let deadline_ms = u32::try_from(d.as_millis()).unwrap_or(u32::MAX);
274        let mut sched = SchedulerContext::new(Some(deadline_ms));
275        // The absolute deadline is whatever `with_deadline` previously
276        // installed (if anything); we copy it onto the scheduler so
277        // `yield_now()` resolves from the same `Instant` that
278        // `BackPressure::with_deadline_hint` consumes. If
279        // `with_deadline` has not yet been called the field is `None`
280        // and the scheduler falls back to its legacy ms-based path
281        // until the executor re-arms us at the start of a call.
282        sched.set_bp_deadline_instant(self.deadline);
283        self.scheduler = sched;
284        self
285    }
286
287    /// Borrow the cooperative-scheduler context. Used by the
288    /// `wasi:scheduler/host` linker registration to plumb the
289    /// per-instance state into the `yield` and `deadline-remaining-ms`
290    /// host functions.
291    pub fn scheduler(&self) -> &SchedulerContext {
292        &self.scheduler
293    }
294
295    /// Re-arm the scheduler context's wall-clock origin. Called from
296    /// [`crate::executor::TensorWasmExecutor::call_export`] at the
297    /// start of each call so back-to-back invocations each see a
298    /// fresh elapsed window (mirroring the per-call re-arm of
299    /// [`Self::deadline`] and the wasmtime epoch deadline).
300    ///
301    /// T36: also re-installs the scheduler's absolute `Instant`
302    /// deadline so the guest's cooperative-yield verdicts agree with
303    /// the back-pressure semaphore's acquire decisions for THIS
304    /// call's window — not the previous one. Pulls the current
305    /// `Self::deadline` value so a fresh `call_export` (which
306    /// re-seeds `deadline = now + d` before calling this) gets the
307    /// up-to-date `Instant` propagated through.
308    pub(crate) fn rearm_scheduler(&mut self) {
309        self.scheduler.rearm_with_instant(self.deadline);
310    }
311
312    /// Increment the kernel dispatch counter and return the new value.
313    pub fn record_kernel_dispatch(&self) -> u64 {
314        self.kernel_dispatches.fetch_add(1, Ordering::Relaxed) + 1
315    }
316
317    /// Add `bytes` to the GPU allocation total.
318    pub fn record_gpu_alloc(&self, bytes: u64) {
319        self.gpu_bytes_allocated.fetch_add(bytes, Ordering::Relaxed);
320    }
321
322    /// True if the deadline has elapsed.
323    pub fn is_past_deadline(&self) -> bool {
324        match self.deadline {
325            Some(d) => Instant::now() >= d,
326            None => false,
327        }
328    }
329
330    /// Borrow the per-instance JIT scratch arena mutably.
331    ///
332    /// Used by the host imports registered via
333    /// [`crate::jit_dispatch::add_jit_dispatch_to_linker`] to keep one
334    /// bump cursor and LIFO `live` stack per store, even when multiple
335    /// instances share a single [`wasmtime::Linker`].
336    pub fn jit_arena_mut(&mut self) -> &mut ArenaState {
337        &mut self.jit_arena
338    }
339
340    /// Borrow the per-instance JIT scratch arena.
341    ///
342    /// Read-only counterpart to [`InstanceState::jit_arena_mut`]; useful
343    /// for tests asserting per-store isolation of the bump cursor.
344    pub fn jit_arena(&self) -> &ArenaState {
345        &self.jit_arena
346    }
347}
348
349impl JitArenaProvider for InstanceState {
350    fn jit_arena_mut(&mut self) -> &mut ArenaState {
351        InstanceState::jit_arena_mut(self)
352    }
353}
354
355impl HasStreaming for InstanceState {
356    fn streaming(&self) -> &StreamingContext {
357        InstanceState::streaming(self)
358    }
359}
360
361impl HasInput for InstanceState {
362    fn input(&self) -> &InputContext {
363        InstanceState::input(self)
364    }
365}
366
367/// A running Wasm instance.
368pub struct TensorWasmInstance {
369    /// Wasmtime store driving execution.
370    pub(crate) store: wasmtime::Store<InstanceState>,
371    /// Wasmtime instance after linking exports.
372    pub(crate) instance: wasmtime::Instance,
373}
374
375impl TensorWasmInstance {
376    /// Construct a `TensorWasmInstance` from a fully-instantiated wasmtime
377    /// `(store, instance)` pair. Typically called by
378    /// [`TensorWasmExecutor::spawn_instance`](crate::executor::TensorWasmExecutor::spawn_instance).
379    pub fn new(store: wasmtime::Store<InstanceState>, instance: wasmtime::Instance) -> Self {
380        Self { store, instance }
381    }
382
383    /// Tenant owning this instance.
384    pub fn tenant_id(&self) -> TenantId {
385        self.store.data().tenant_id
386    }
387
388    /// Unique instance identifier.
389    pub fn instance_id(&self) -> InstanceId {
390        self.store.data().instance_id
391    }
392
393    /// Borrow the wasmtime [`wasmtime::Store`].
394    pub fn store(&mut self) -> &mut wasmtime::Store<InstanceState> {
395        &mut self.store
396    }
397
398    /// Borrow the wasmtime [`wasmtime::Instance`].
399    pub fn wasmtime_instance(&self) -> &wasmtime::Instance {
400        &self.instance
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::time::Duration;
408
409    #[test]
410    fn state_records_identity() {
411        let s = InstanceState::new(TenantId(1), InstanceId(2));
412        assert_eq!(s.tenant_id, TenantId(1));
413        assert_eq!(s.instance_id, InstanceId(2));
414        assert_eq!(s.kernel_dispatches.load(Ordering::Relaxed), 0);
415    }
416
417    #[test]
418    fn record_kernel_dispatch_increments() {
419        let s = InstanceState::new(TenantId(0), InstanceId(0));
420        assert_eq!(s.record_kernel_dispatch(), 1);
421        assert_eq!(s.record_kernel_dispatch(), 2);
422        assert_eq!(s.record_kernel_dispatch(), 3);
423    }
424
425    #[test]
426    fn record_gpu_alloc_sums() {
427        let s = InstanceState::new(TenantId(0), InstanceId(0));
428        s.record_gpu_alloc(1024);
429        s.record_gpu_alloc(2048);
430        assert_eq!(s.gpu_bytes_allocated.load(Ordering::Relaxed), 3072);
431    }
432
433    #[test]
434    fn deadline_check() {
435        let past = Instant::now() - Duration::from_secs(1);
436        let s = InstanceState::new(TenantId(0), InstanceId(0)).with_deadline(past);
437        assert!(s.is_past_deadline());
438
439        let future = Instant::now() + Duration::from_secs(60);
440        let s = InstanceState::new(TenantId(0), InstanceId(0)).with_deadline(future);
441        assert!(!s.is_past_deadline());
442
443        let s = InstanceState::new(TenantId(0), InstanceId(0));
444        assert!(!s.is_past_deadline());
445    }
446
447    #[test]
448    fn input_empty_by_default() {
449        // A freshly-constructed InstanceState stages no guest input, so
450        // `wasi:tensor/host.input-len` returns 0.
451        let s = InstanceState::new(TenantId(0), InstanceId(0));
452        assert!(s.input().is_empty());
453        assert_eq!(s.input().len_u32(), 0);
454    }
455
456    #[test]
457    fn with_input_stages_bytes() {
458        let s = InstanceState::new(TenantId(0), InstanceId(0))
459            .with_input(InputContext::new(b"prompt".to_vec()));
460        assert!(!s.input().is_empty());
461        assert_eq!(s.input().bytes(), b"prompt");
462    }
463
464    #[test]
465    fn scheduler_unbounded_by_default() {
466        // A freshly-constructed InstanceState carries an unbounded
467        // SchedulerContext so guests that import `wasi:scheduler/host`
468        // see a working surface even without a configured deadline.
469        let s = InstanceState::new(TenantId(0), InstanceId(0));
470        assert_eq!(s.scheduler().deadline_ms(), None);
471    }
472
473    #[test]
474    fn with_deadline_duration_seeds_scheduler() {
475        // The scheduler context's budget mirrors the configured
476        // wall-clock deadline so cooperative yields and the epoch
477        // interrupt agree on when the deadline trips.
478        let s = InstanceState::new(TenantId(0), InstanceId(0))
479            .with_deadline_duration(Duration::from_millis(750));
480        assert_eq!(s.scheduler().deadline_ms(), Some(750));
481    }
482
483    #[test]
484    fn with_deadline_duration_saturates_at_u32_max() {
485        // A pathologically long deadline (decades) saturates the u32
486        // ms field rather than wrapping. The epoch interrupt would
487        // never fire within u32::MAX ms either, so reporting u32::MAX
488        // is honest.
489        let s = InstanceState::new(TenantId(0), InstanceId(0))
490            .with_deadline_duration(Duration::from_secs(60 * 60 * 24 * 365 * 100));
491        assert_eq!(s.scheduler().deadline_ms(), Some(u32::MAX));
492    }
493
494    #[test]
495    fn with_deadline_duration_propagates_instant_when_present() {
496        // T36: when both `with_deadline` (sets the absolute Instant)
497        // and `with_deadline_duration` are chained, the scheduler
498        // context picks up the same Instant for its BP-aligned query
499        // path so guests and BackPressure agree on the trip point.
500        let at = Instant::now() + Duration::from_millis(500);
501        let s = InstanceState::new(TenantId(0), InstanceId(0))
502            .with_deadline(at)
503            .with_deadline_duration(Duration::from_millis(500));
504        assert_eq!(s.scheduler().bp_deadline_instant(), Some(at));
505    }
506
507    #[test]
508    fn rearm_scheduler_updates_bp_deadline_instant() {
509        // T36: re-arming the scheduler at the start of a call
510        // installs the FRESH absolute Instant (so back-to-back calls
511        // observe distinct windows). Simulate the executor's per-call
512        // re-arm by overwriting `deadline` and confirming the
513        // scheduler tracks it.
514        let mut s = InstanceState::new(TenantId(0), InstanceId(0))
515            .with_deadline(Instant::now() + Duration::from_millis(100))
516            .with_deadline_duration(Duration::from_millis(100));
517        let next = Instant::now() + Duration::from_secs(5);
518        s.deadline = Some(next);
519        s.rearm_scheduler();
520        assert_eq!(s.scheduler().bp_deadline_instant(), Some(next));
521    }
522}