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}