tensor_wasm_api/routes.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! REST route handlers (deploy, invoke, metrics, healthz).
5//!
6//! All handlers operate on a shared [`AppState`] containing in-memory registries
7//! of deployed functions and pending jobs, plus a shared [`TensorWasmMetrics`]
8//! registry and a [`TensorWasmExecutor`]. Wasm bytes are accepted as base64; the
9//! deploy path runs full `wasmparser` validation and stores the bytes (as
10//! `Arc<[u8]>` so concurrent invocations share a single allocation), and the
11//! synchronous invoke path drives `tensor_wasm_exec::executor::TensorWasmExecutor` to
12//! spawn, call `_start` / `main`, and terminate the instance. The async
13//! `invoke-async` path spawns the same flow on a Tokio task and records
14//! progress in the shared job registry for `GET /jobs/{id}` polling.
15//!
16//! ## Error envelope
17//!
18//! Every error response carries the JSON envelope:
19//!
20//! ```json
21//! { "error": { "kind": "<machine-readable>", "message": "<human-readable>" } }
22//! ```
23//!
24//! `kind` strings are stable; `message` strings are not.
25
26use std::sync::Arc;
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28
29use axum::{
30 body::Body,
31 extract::{rejection::JsonRejection, Extension, Path, State},
32 http::{header, HeaderMap, StatusCode},
33 response::{
34 sse::{Event, KeepAlive, Sse},
35 IntoResponse, Response,
36 },
37 Json,
38};
39use base64::engine::general_purpose::STANDARD as BASE64;
40use base64::Engine;
41use dashmap::DashMap;
42use futures::stream;
43use serde::{Deserialize, Serialize};
44use tensor_wasm_core::error::TensorWasmError;
45use tensor_wasm_core::metrics::TensorWasmMetrics;
46use tensor_wasm_core::types::{InstanceId, TenantId};
47use tensor_wasm_exec::engine::TensorWasmEngine;
48use tensor_wasm_exec::executor::{ExecError, SpawnConfig, TensorWasmExecutor, WasmArg};
49use tensor_wasm_wasi_gpu::streaming::StreamingContext;
50use uuid::Uuid;
51
52/// Default per-invocation deadline used by the synchronous `/invoke` handler.
53const INVOKE_DEFAULT_DEADLINE: Duration = Duration::from_secs(30);
54
55/// Minimum length, in bytes, of a Wasm module: the 4-byte `\0asm` magic plus
56/// the 4-byte version field. Anything shorter cannot be a valid module.
57pub const WASM_MIN_HEADER_BYTES: usize = 8;
58
59/// Body-size threshold above which base64 decoding is moved to
60/// [`tokio::task::spawn_blocking`]. Below this the inline decode is cheaper
61/// than the spawn handoff.
62// T19 perf: lowered from 256 KiB to 32 KiB so a 32-concurrent burst
63// (per-tenant rate limit) of large-payload invokes can't occupy reactor
64// threads on inline base64 decode.
65pub const BASE64_OFFLOAD_THRESHOLD: usize = 32 * 1024;
66
67/// Maximum byte-length of a tenant-supplied function name. Names are echoed
68/// back on every read of [`FunctionRecord`], so an unchecked name field
69/// would let a caller pin arbitrarily many MiB of strings in the in-memory
70/// registry. 256 bytes is generous for any realistic display label while
71/// keeping the worst-case footprint of a million records under ~256 MiB.
72pub const MAX_FUNCTION_NAME_BYTES: usize = 256;
73
74/// Maximum number of elements accepted in an [`InvokeRequest::args`] list.
75///
76/// The global body-size limit already bounds the *encoded* payload, but a
77/// caller could still pack tens of thousands of tiny scalar args into a
78/// small JSON body and force the executor to allocate / thread an
79/// oversized [`WasmArg`] vector. 256 is far above any realistic export
80/// arity while keeping the per-invocation argument footprint bounded.
81/// Exceeding it is rejected with `400 too_many_args` in
82/// `parse_invoke_args`.
83pub const MAX_INVOKE_ARGS: usize = 256;
84
85/// SECURITY (async-invoke resource amplification, MEDIUM): ceiling on the
86/// number of concurrently-*outstanding* async invocation jobs.
87///
88/// The 30s request timeout and the invoke `ConcurrencyLimitLayer` only bound
89/// the HTTP *request* future. On the `invoke-async` / `invoke-stream` paths
90/// the real work runs in a detached `tokio::spawn` that outlives the request:
91/// the 202/stream response returns immediately, freeing the request
92/// concurrency slot, while the spawned task keeps running. Without an
93/// independent bound a caller can fire requests far faster than jobs complete
94/// and drive an unbounded number of live executor instances + tasks.
95///
96/// We gate new spawns on the existing `jobs_active` gauge (the same counter
97/// the `JobsActiveGuard` maintains): before spawning we check the gauge
98/// against this ceiling and reject with `503 capacity_exhausted` when it is
99/// already saturated. 256 mirrors the engine-wide live-instance default so
100/// the two limits are in the same order of magnitude; an operator who raises
101/// the executor cap should raise this in lockstep.
102pub const MAX_OUTSTANDING_ASYNC_JOBS: usize = 256;
103
104/// SECURITY (unbounded `jobs` registry, MEDIUM): hard cap on the number of
105/// [`JobRecord`]s retained in the in-memory `jobs` map.
106///
107/// Completed / failed jobs were never evicted, so a caller could anchor
108/// arbitrarily many records (each carrying a `result` `serde_json::Value`) by
109/// dispatching async invocations and never polling. When an insert would push
110/// the map past this cap, `evict_jobs_if_over_cap` drops terminal
111/// (`Completed` / `Failed`) records oldest-first by `created_unix_ms`,
112/// falling back to the oldest records of any status only if no terminal
113/// record can be found. 4096 keeps the steady-state footprint bounded while
114/// leaving generous head-room for legitimate poll-after-dispatch workflows.
115pub const MAX_JOB_RECORDS: usize = 4096;
116
117// ---------------------------------------------------------------------------
118// State records
119// ---------------------------------------------------------------------------
120
121/// A deployed function as held in memory by the API gateway.
122///
123/// `wasm_bytes` is intentionally excluded from the serialised wire form: the
124/// API never echoes raw module bytes back to callers. The storage type is
125/// `Arc<[u8]>` so concurrent invocations share a single allocation rather
126/// than cloning the bytes on every spawn.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct FunctionRecord {
129 /// Server-assigned identifier.
130 pub id: Uuid,
131 /// Tenant that deployed this function. Set from the request tenant at
132 /// `POST /functions` time and never mutated. The invoke / delete handlers
133 /// compare this against the request's claimed tenant so a wildcard-scoped
134 /// caller from tenant B cannot drive tenant A's record (api S-IDOR).
135 pub tenant_id: TenantId,
136 /// Tenant-supplied display name.
137 pub name: String,
138 /// Decoded Wasm bytes, refcounted. Not serialised — see struct-level doc.
139 #[serde(skip, default = "default_wasm_bytes")]
140 pub wasm_bytes: Arc<[u8]>,
141 /// Millisecond-precision Unix timestamp of deploy.
142 pub created_unix_ms: u64,
143}
144
145fn default_wasm_bytes() -> Arc<[u8]> {
146 Arc::from(Vec::<u8>::new())
147}
148
149/// Status of an asynchronously dispatched invocation.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum JobStatus {
153 /// Job is queued or in flight.
154 Pending,
155 /// Job completed successfully; `result` holds the value.
156 Completed,
157 /// Job failed; `result` carries `{ "kind": ..., "message": ... }`.
158 Failed,
159}
160
161/// An async-invocation record returned by `GET /jobs/{id}`.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct JobRecord {
164 /// Server-assigned identifier of the job.
165 pub id: Uuid,
166 /// Function this invocation was dispatched against.
167 pub function_id: Uuid,
168 /// Tenant this invocation was dispatched under. Set from the request
169 /// tenant at `invoke-async` time and never mutated. `GET /jobs/{id}`
170 /// compares this against the request's claimed tenant so a
171 /// wildcard-scoped caller from another tenant cannot read this job's
172 /// result payload (api S-32).
173 pub tenant_id: TenantId,
174 /// Current status.
175 pub status: JobStatus,
176 /// Result payload (set when `status` transitions to `completed` or `failed`).
177 ///
178 /// For `completed` jobs, this holds the JSON result of the invocation.
179 /// For `failed` jobs, this is `{ "kind": "...", "message": "..." }`
180 /// mirroring the synchronous error envelope shape.
181 #[serde(skip_serializing_if = "Option::is_none")]
182 pub result: Option<serde_json::Value>,
183 /// Millisecond-precision Unix timestamp of dispatch.
184 pub created_unix_ms: u64,
185}
186
187/// Process-global state shared by every handler.
188///
189/// `Arc<AppState>` is cloned into the router via `with_state`; the inner
190/// `DashMap`s sit behind their own `Arc` so cheap clones of the maps remain
191/// possible across the codebase.
192#[derive(Clone)]
193pub struct AppState {
194 /// Deployed-function registry, keyed by id.
195 pub functions: Arc<DashMap<Uuid, FunctionRecord>>,
196 /// Async-job registry, keyed by id.
197 pub jobs: Arc<DashMap<Uuid, JobRecord>>,
198 /// Shared metrics registry. Cloned into the executor so spawn/terminate
199 /// counters and the `/metrics` scrape view the same atomics.
200 pub metrics: Arc<TensorWasmMetrics>,
201 /// Wasm executor driving the synchronous `/invoke` path.
202 pub executor: Arc<TensorWasmExecutor>,
203 /// Optional kernel registry (B6.4 / roadmap feature #3). `None` when
204 /// `TENSOR_WASM_API_KERNEL_HMAC_KEY` is unset; the `/kernels` routes
205 /// return `503 kernel_registry_not_configured` in that case so
206 /// client tools can detect feature availability without inspecting
207 /// the URL surface. Only compiled when the `kernel-registry-api`
208 /// feature is enabled — the default build keeps the lean dep graph.
209 #[cfg(feature = "kernel-registry-api")]
210 pub kernel_registry: Option<Arc<dyn tensor_wasm_jit::registry::KernelRegistry>>,
211 /// OpenAI `model → FunctionId` resolution map (T41). Read from
212 /// `TENSOR_WASM_API_OPENAI_MODEL_MAP` at startup; empty map means
213 /// every OpenAI request returns `404 model_not_found`. See
214 /// [`crate::openai_translator`] for the env-var grammar.
215 pub openai_model_map: crate::openai_translator::ModelMap,
216 /// Top-level [`AppConfig`](crate::config::AppConfig) carrying the
217 /// snapshot HMAC signing key and the `require_signature` toggle (M5).
218 ///
219 /// Read from the environment (`TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` /
220 /// `TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE`) in
221 /// [`build_router`](crate::server::build_router) and threaded here so
222 /// the `/snapshot/save` and `/snapshot/restore` handlers can sign and
223 /// verify blobs with the operator's configured key. When the key is
224 /// `None` the snapshot routes return `503 snapshot_signing_not_configured`
225 /// — mirroring the kernel-registry posture so a client can detect the
226 /// feature being disabled without inspecting the URL surface.
227 pub app_config: crate::config::AppConfig,
228}
229
230impl std::fmt::Debug for AppState {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 f.debug_struct("AppState")
233 .field("functions", &self.functions.len())
234 .field("jobs", &self.jobs.len())
235 .finish()
236 }
237}
238
239impl AppState {
240 /// Build the inner struct (without the outer `Arc`). Returns
241 /// [`TensorWasmError::WasmCompile`] if `TensorWasmEngine::new` fails — typically a
242 /// fatal host misconfiguration (unsupported wasmtime strategy, etc.).
243 ///
244 /// When the `kernel-registry-api` feature is enabled, also reads
245 /// `TENSOR_WASM_API_KERNEL_HMAC_KEY` (64-hex chars / 32 bytes) and
246 /// constructs an `InMemoryRegistry` keyed off it. An unset env var
247 /// leaves `kernel_registry` at `None` so the `/kernels` routes
248 /// return `503` rather than silently accepting publishes under a
249 /// zero key.
250 fn try_build() -> Result<Self, TensorWasmError> {
251 let metrics = Arc::new(TensorWasmMetrics::new());
252 let engine = Arc::new(
253 TensorWasmEngine::new()
254 .map_err(|e| TensorWasmError::WasmCompile(format!("{e:#}").into()))?,
255 );
256 let executor = Arc::new(TensorWasmExecutor::with_metrics(engine, (*metrics).clone()));
257 Ok(Self {
258 functions: Arc::new(DashMap::new()),
259 jobs: Arc::new(DashMap::new()),
260 metrics,
261 executor,
262 #[cfg(feature = "kernel-registry-api")]
263 kernel_registry: build_kernel_registry_from_env(),
264 openai_model_map: crate::openai_translator::model_map_from_env(),
265 // Default to the empty config (no snapshot key). Production
266 // wiring flows the parsed `AppConfig::from_env()` in through
267 // [`AppState::with_app_config`] from `build_router`; tests that
268 // construct `AppState::default()` get the keyless config and
269 // the `/snapshot/*` routes then report
270 // `503 snapshot_signing_not_configured`.
271 app_config: crate::config::AppConfig::default(),
272 })
273 }
274
275 /// Install the top-level [`AppConfig`](crate::config::AppConfig),
276 /// carrying the snapshot HMAC key + require-signature toggle (M5).
277 ///
278 /// Called by [`build_router`](crate::server::build_router) with the
279 /// result of [`AppConfig::from_env`](crate::config::AppConfig::from_env)
280 /// so the `/snapshot/*` handlers consume the operator-configured key.
281 /// Public so integration tests can drive the snapshot routes with an
282 /// explicit key without poisoning the process environment.
283 #[must_use]
284 pub fn with_app_config(mut self, cfg: crate::config::AppConfig) -> Self {
285 self.app_config = cfg;
286 self
287 }
288
289 /// Install an explicit OpenAI model map, bypassing the env-var
290 /// initialisation in [`Self::try_build`].
291 ///
292 /// Test-only convenience so integration tests can drive the OpenAI
293 /// shim without poisoning the process environment.
294 #[doc(hidden)]
295 pub fn with_openai_model_map(mut self, map: crate::openai_translator::ModelMap) -> Self {
296 self.openai_model_map = map;
297 self
298 }
299
300 /// Fallible constructor. Returns a [`TensorWasmError`] if the underlying
301 /// wasmtime engine cannot be initialised (e.g. unsupported strategy
302 /// on the host). Production callers should use this and surface the
303 /// failure through their own startup path.
304 pub fn try_new() -> Result<Arc<Self>, TensorWasmError> {
305 Self::try_build().map(Arc::new)
306 }
307
308 /// Construct an `AppState` wrapped in `Arc` for use with
309 /// `Router::with_state`. Panics on engine initialisation failure.
310 ///
311 /// Prefer [`AppState::try_new`] in production code; this convenience
312 /// wrapper exists for parity with [`Default`] and for tests.
313 pub fn new() -> Arc<Self> {
314 Self::try_new().expect("TensorWasmEngine::new must succeed for AppState::new()")
315 }
316
317 /// Override the metrics registry, rebuilding the executor so its
318 /// spawn/terminate counters share the supplied handle.
319 ///
320 /// Useful for tests that need to inspect counter values, or for embedders
321 /// who construct a process-wide registry separately. Panics on engine
322 /// initialisation failure for ergonomic parity with [`Self::new`].
323 pub fn with_metrics(mut self, metrics: Arc<TensorWasmMetrics>) -> Self {
324 let engine = Arc::new(
325 TensorWasmEngine::new()
326 .expect("TensorWasmEngine::new must succeed for AppState::with_metrics"),
327 );
328 self.executor = Arc::new(TensorWasmExecutor::with_metrics(engine, (*metrics).clone()));
329 self.metrics = metrics;
330 self
331 }
332
333 /// Install an explicit kernel registry, bypassing the env-var
334 /// initialisation in [`Self::try_build`].
335 ///
336 /// Test-only convenience so integration tests can drive `/kernels`
337 /// without poisoning the process environment with a hex-encoded
338 /// HMAC key. `#[doc(hidden)]` because production code should always
339 /// flow through `try_build`'s env-var read.
340 #[doc(hidden)]
341 #[cfg(feature = "kernel-registry-api")]
342 pub fn with_kernel_registry(
343 mut self,
344 registry: Arc<dyn tensor_wasm_jit::registry::KernelRegistry>,
345 ) -> Self {
346 self.kernel_registry = Some(registry);
347 self
348 }
349}
350
351/// Read `TENSOR_WASM_API_KERNEL_HMAC_KEY` and build an
352/// `Arc<dyn KernelRegistry>` from it.
353///
354/// Returns `None` when the variable is unset / empty, and also when the
355/// value is malformed (not 64 hex chars). Malformed values are logged
356/// at `warn` so an operator who typoed a key sees a startup signal — we
357/// deliberately do NOT panic, because the gateway should still come up
358/// and serve the non-kernel routes; the `/kernels` endpoints will then
359/// return `503 kernel_registry_not_configured` and clients can correct
360/// the deploy without a full restart loop.
361///
362/// ## T35: disk-backed registry selection
363///
364/// If `TENSOR_WASM_API_KERNEL_REGISTRY_DIR` is also set (and non-empty),
365/// the gateway constructs a [`tensor_wasm_jit::registry::DiskRegistry`]
366/// rooted at that path instead of the historical
367/// [`tensor_wasm_jit::registry::InMemoryRegistry`]. The disk-backed path
368/// survives process restarts; the in-memory path is now considered
369/// dev-only. An `open` failure on the disk path falls back to the
370/// in-memory registry and warns rather than dropping the routes
371/// entirely — operators who relied on the env-var contract still get a
372/// working `/kernels` surface; they just lose the persistence on
373/// restart until the disk-path issue is resolved.
374///
375/// The behaviour mirrors `AppConfig::from_env` for the snapshot HMAC key
376/// (see `crate::config`) except that the snapshot path returns a hard
377/// error: the kernel registry is a non-critical add-on whereas a snapshot
378/// signing key misconfiguration would silently downgrade integrity.
379#[cfg(feature = "kernel-registry-api")]
380fn build_kernel_registry_from_env() -> Option<Arc<dyn tensor_wasm_jit::registry::KernelRegistry>> {
381 /// Environment variable carrying the hex-encoded 32-byte HMAC-SHA256
382 /// key the kernel registry uses to verify inbound manifests.
383 const ENV_KERNEL_HMAC_KEY: &str = "TENSOR_WASM_API_KERNEL_HMAC_KEY";
384 /// T35: when set, the gateway uses a disk-persisted registry
385 /// rooted at this path. Unset = legacy in-memory registry.
386 const ENV_KERNEL_REGISTRY_DIR: &str = "TENSOR_WASM_API_KERNEL_REGISTRY_DIR";
387
388 let raw = match std::env::var(ENV_KERNEL_HMAC_KEY) {
389 Ok(s) => s,
390 Err(_) => return None,
391 };
392 let trimmed = raw.trim();
393 if trimmed.is_empty() {
394 return None;
395 }
396 if trimmed.len() != 64 {
397 tracing::warn!(
398 target: "tensor_wasm_api::routes",
399 var = ENV_KERNEL_HMAC_KEY,
400 actual_len = trimmed.len(),
401 "kernel registry HMAC key must be exactly 64 hex characters; \
402 leaving registry unconfigured (the /kernels routes will return \
403 503 kernel_registry_not_configured)",
404 );
405 return None;
406 }
407 let mut key = [0u8; 32];
408 for (i, slot) in key.iter_mut().enumerate() {
409 let bytes = trimmed.as_bytes();
410 let hi = match hex_nibble_local(bytes[i * 2]) {
411 Some(v) => v,
412 None => {
413 tracing::warn!(
414 target: "tensor_wasm_api::routes",
415 var = ENV_KERNEL_HMAC_KEY,
416 "kernel registry HMAC key contains non-hex characters; \
417 leaving registry unconfigured",
418 );
419 return None;
420 }
421 };
422 let lo = match hex_nibble_local(bytes[i * 2 + 1]) {
423 Some(v) => v,
424 None => {
425 tracing::warn!(
426 target: "tensor_wasm_api::routes",
427 var = ENV_KERNEL_HMAC_KEY,
428 "kernel registry HMAC key contains non-hex characters; \
429 leaving registry unconfigured",
430 );
431 return None;
432 }
433 };
434 *slot = (hi << 4) | lo;
435 }
436
437 // T35: prefer the disk-backed registry when the env var points at a
438 // directory. A failed open warns and falls back to the in-memory
439 // backend rather than refusing to wire `/kernels` at all — the
440 // operator surface is the same on failure, just non-persistent.
441 if let Ok(dir_raw) = std::env::var(ENV_KERNEL_REGISTRY_DIR) {
442 let dir_trimmed = dir_raw.trim();
443 if !dir_trimmed.is_empty() {
444 let dir = std::path::PathBuf::from(dir_trimmed);
445 match tensor_wasm_jit::registry::DiskRegistry::open(dir.clone(), key) {
446 Ok(reg) => {
447 tracing::info!(
448 target: "tensor_wasm_api::routes",
449 dir = %dir.display(),
450 "kernel registry disk-backed (T35); /kernels routes live",
451 );
452 return Some(Arc::new(reg));
453 }
454 Err(e) => {
455 tracing::warn!(
456 target: "tensor_wasm_api::routes",
457 dir = %dir.display(),
458 error = %e,
459 var = ENV_KERNEL_REGISTRY_DIR,
460 "disk-backed kernel registry open failed; falling back \
461 to in-memory registry (manifests will NOT survive restart)",
462 );
463 }
464 }
465 }
466 }
467
468 tracing::info!(
469 target: "tensor_wasm_api::routes",
470 "kernel registry HMAC key configured (64 chars hex); /kernels routes live (in-memory)",
471 );
472 Some(Arc::new(tensor_wasm_jit::registry::InMemoryRegistry::new(
473 key,
474 )))
475}
476
477/// Local hex-nibble decoder for the kernel registry env-var path. We do
478/// NOT route this through `crate::config::parse_hex_key` because that
479/// helper returns a typed [`crate::config::ConfigError`] which the
480/// kernel-registry initialiser deliberately discards (it logs and
481/// degrades rather than failing startup).
482#[cfg(feature = "kernel-registry-api")]
483fn hex_nibble_local(c: u8) -> Option<u8> {
484 match c {
485 b'0'..=b'9' => Some(c - b'0'),
486 b'a'..=b'f' => Some(c - b'a' + 10),
487 b'A'..=b'F' => Some(c - b'A' + 10),
488 _ => None,
489 }
490}
491
492impl Default for AppState {
493 /// Default `AppState`. Calls the internal builder and unwraps for the
494 /// same reasons documented on [`AppState::new`]: in practice
495 /// `TensorWasmEngine::new` only fails on host misconfiguration, which is a
496 /// fatal startup condition. Used by tests via `AppState::default()`.
497 fn default() -> Self {
498 Self::try_build().expect("TensorWasmEngine::new must succeed for AppState::default")
499 }
500}
501
502fn now_unix_ms() -> u64 {
503 match SystemTime::now().duration_since(UNIX_EPOCH) {
504 Ok(d) => d.as_millis() as u64,
505 Err(e) => {
506 // System clock is set before 1970-01-01 — almost certainly a
507 // misconfigured container. Log loud and return 0; a bogus
508 // timestamp is better than panicking a request handler.
509 tracing::warn!(
510 target: "tensor_wasm_api::routes",
511 error = %e,
512 "system clock is before UNIX_EPOCH; returning 0 timestamp",
513 );
514 0
515 }
516 }
517}
518
519/// SECURITY (unbounded `jobs` registry, MEDIUM): bound the `jobs` map by
520/// evicting old records once it exceeds [`MAX_JOB_RECORDS`].
521///
522/// Called immediately after a new [`JobRecord`] is inserted. Eviction policy:
523///
524/// 1. Prefer dropping **terminal** records (`Completed` / `Failed`) — a
525/// polled-or-abandoned result is the safe thing to forget; a `Pending`
526/// job is still in flight and being mutated by its spawned task.
527/// 2. Among terminal records, evict **oldest first** by `created_unix_ms`.
528/// 3. Only if there are not enough terminal records to get back under the cap
529/// do we fall back to evicting the oldest records regardless of status
530/// (a pathological all-`Pending` flood) — better to forget an in-flight
531/// job's eventual result slot than to let the map grow without bound.
532///
533/// Concurrency: `DashMap` does not expose a transactional "snapshot + remove"
534/// so under concurrent inserts the map may briefly sit a few records above
535/// the cap between an insert and this pass; that is acceptable — the cap is a
536/// memory-pressure bound, not a hard invariant. We compute the eviction set
537/// from a borrowed scan (which only read-locks the iterated shards) and then
538/// `remove` outside any held shard guard so we never deadlock on a shard we
539/// are already iterating.
540fn evict_jobs_if_over_cap(jobs: &DashMap<Uuid, JobRecord>) {
541 // Fast path: the common case is well under the cap, so avoid the
542 // allocation + scan entirely.
543 let len = jobs.len();
544 if len <= MAX_JOB_RECORDS {
545 return;
546 }
547 let overflow = len - MAX_JOB_RECORDS;
548
549 // Collect `(created_unix_ms, id, is_terminal)` for every record. The scan
550 // read-locks each shard transiently; we copy only the small key/sort
551 // fields out, never the heavy `result` payload.
552 let mut entries: Vec<(u64, Uuid, bool)> = jobs
553 .iter()
554 .map(|e| {
555 let rec = e.value();
556 let terminal = matches!(rec.status, JobStatus::Completed | JobStatus::Failed);
557 (rec.created_unix_ms, *e.key(), terminal)
558 })
559 .collect();
560
561 // Terminal records first (so they sort to the front of the eviction
562 // candidate list), then oldest-first within each group.
563 entries.sort_unstable_by(|a, b| {
564 // `a.2`/`b.2` is `is_terminal`; we want terminal (true) before
565 // non-terminal (false), hence reverse-compare the bool.
566 b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))
567 });
568
569 for (_, id, _) in entries.into_iter().take(overflow) {
570 jobs.remove(&id);
571 }
572}
573
574// ---------------------------------------------------------------------------
575// Error envelope
576// ---------------------------------------------------------------------------
577
578/// Inner body of the JSON error envelope.
579#[derive(Debug, Serialize)]
580pub struct ApiErrorBody {
581 /// Machine-readable, stable identifier.
582 pub kind: String,
583 /// Human-readable description (not stable across versions).
584 pub message: String,
585}
586
587/// Top-level JSON error envelope as serialised to the wire:
588/// `{"error":{"kind":..., "message":...}}`.
589#[derive(Debug, Serialize)]
590pub struct ApiErrorEnvelope {
591 /// Inner error.
592 pub error: ApiErrorBody,
593}
594
595/// Error type returned by every fallible handler.
596#[derive(Debug)]
597pub struct ApiError {
598 /// HTTP status code to send.
599 pub status: StatusCode,
600 /// Stable machine-readable identifier (the `kind` field on the wire).
601 pub kind: String,
602 /// Human-readable description.
603 pub message: String,
604}
605
606impl ApiError {
607 /// Construct a `400 Bad Request` with the given `kind` and `message`.
608 pub fn bad_request(kind: impl Into<String>, message: impl Into<String>) -> Self {
609 Self {
610 status: StatusCode::BAD_REQUEST,
611 kind: kind.into(),
612 message: message.into(),
613 }
614 }
615
616 /// Construct a `403 Forbidden` with the given `kind` and `message`. Used
617 /// by per-tenant authorization to return `kind = "tenant_scope_denied"`
618 /// when the caller's bearer token does not cover the bound tenant.
619 pub fn forbidden(kind: impl Into<String>, message: impl Into<String>) -> Self {
620 Self {
621 status: StatusCode::FORBIDDEN,
622 kind: kind.into(),
623 message: message.into(),
624 }
625 }
626
627 /// Construct a `404 Not Found` with `kind = "not_found"`.
628 pub fn not_found(message: impl Into<String>) -> Self {
629 Self {
630 status: StatusCode::NOT_FOUND,
631 kind: "not_found".to_string(),
632 message: message.into(),
633 }
634 }
635
636 /// Construct a `409 Conflict` with the given `kind` and `message`.
637 ///
638 /// Used by the kernel registry's `POST /kernels` path to surface
639 /// `kind = "already_registered"` when a manifest with the same
640 /// `name@version` has already been published. The `(409, kind)`
641 /// pair is the documented contract for "the request is well-formed
642 /// but would violate a uniqueness invariant"; clients should NOT
643 /// retry without changing the request (no `Retry-After` header).
644 pub fn conflict(kind: impl Into<String>, message: impl Into<String>) -> Self {
645 Self {
646 status: StatusCode::CONFLICT,
647 kind: kind.into(),
648 message: message.into(),
649 }
650 }
651
652 /// Construct a `503 Service Unavailable` with the given `kind` and
653 /// `message`.
654 ///
655 /// Used by the kernel registry routes to surface
656 /// `kind = "kernel_registry_not_configured"` when the gateway is
657 /// running without `TENSOR_WASM_API_KERNEL_HMAC_KEY` set. Distinct
658 /// from `capacity_exhausted` (also 503) because the failure mode is
659 /// configuration, not load — a client should NOT retry, it should
660 /// surface the error to an operator who can flip the env knob.
661 pub fn service_unavailable(kind: impl Into<String>, message: impl Into<String>) -> Self {
662 Self {
663 status: StatusCode::SERVICE_UNAVAILABLE,
664 kind: kind.into(),
665 message: message.into(),
666 }
667 }
668
669 /// Construct a `500 Internal Server Error` with `kind = "internal"`.
670 pub fn internal(message: impl Into<String>) -> Self {
671 Self {
672 status: StatusCode::INTERNAL_SERVER_ERROR,
673 kind: "internal".to_string(),
674 message: message.into(),
675 }
676 }
677
678 /// Construct a `501 Not Implemented` with the given `kind` and `message`.
679 ///
680 /// Used by the snapshot routes (M5) for the parts of full
681 /// live-instance snapshot / restore that still need executor support:
682 /// `/snapshot/save` signs and returns a snapshot blob built from the
683 /// function's deployed bytes (the HMAC envelope layer is fully wired),
684 /// but capturing a *running* instance's linear / GPU memory needs a
685 /// `TensorWasmExecutor` capture hook that does not exist yet — that
686 /// capability surfaces `501 not_implemented` rather than silently
687 /// returning an empty or misleading capture. The `(501, kind)` pair is
688 /// the documented contract; clients should NOT retry.
689 pub fn not_implemented(kind: impl Into<String>, message: impl Into<String>) -> Self {
690 Self {
691 status: StatusCode::NOT_IMPLEMENTED,
692 kind: kind.into(),
693 message: message.into(),
694 }
695 }
696
697 /// Construct a `413 Payload Too Large` with `kind = "body_too_large"`.
698 ///
699 /// Returned when an inbound request body exceeds the global
700 /// [`MAX_REQUEST_BODY_BYTES`](crate::MAX_REQUEST_BODY_BYTES) cap. The
701 /// rejection is surfaced through axum's
702 /// [`DefaultBodyLimit::max`](axum::extract::DefaultBodyLimit::max) at
703 /// extract time — see [`From<JsonRejection>`](#impl-From%3CJsonRejection%3E-for-ApiError)
704 /// for the routing that translates the underlying
705 /// `JsonRejection::BytesRejection(LengthLimitError)` into this variant
706 /// instead of the generic `invalid_json` 400.
707 ///
708 /// The `body_too_large` kind is pinned in [API.md] and
709 /// [openapi.json]; clients can rely on the (kind, status) pair without
710 /// inspecting `message`.
711 pub fn body_too_large(message: impl Into<String>) -> Self {
712 Self {
713 status: StatusCode::PAYLOAD_TOO_LARGE,
714 kind: "body_too_large".to_string(),
715 message: message.into(),
716 }
717 }
718
719 /// Render this error into the canonical `(kind, message)` pair so the
720 /// async job recorder can persist the same shape callers see from the
721 /// synchronous path.
722 pub fn to_kind_message(&self) -> (String, String) {
723 (self.kind.clone(), self.message.clone())
724 }
725}
726
727impl IntoResponse for ApiError {
728 fn into_response(self) -> Response {
729 // Snapshot the kind before moving it into the envelope so the
730 // audit middleware can recover it from the response extensions
731 // without re-parsing the JSON body.
732 let audit_kind = self.kind.clone();
733 let body = ApiErrorEnvelope {
734 error: ApiErrorBody {
735 kind: self.kind,
736 message: self.message,
737 },
738 };
739 let mut response = (self.status, Json(body)).into_response();
740 response
741 .extensions_mut()
742 .insert(crate::audit::AuditOutcomeExt {
743 error_kind: audit_kind,
744 });
745 response
746 }
747}
748
749impl From<JsonRejection> for ApiError {
750 fn from(rej: JsonRejection) -> Self {
751 // When the inbound body exceeded the
752 // [`DefaultBodyLimit::max`](axum::extract::DefaultBodyLimit::max)
753 // cap, axum's `Json` extractor wraps the underlying
754 // `LengthLimitError` as `JsonRejection::BytesRejection`, whose
755 // `status()` is `413 PAYLOAD_TOO_LARGE`. Route those through the
756 // dedicated `body_too_large` envelope rather than the generic
757 // `invalid_json` 400 — the public contract in `API.md` (and the
758 // `oversized_body_is_rejected` test) pins this kind/status pair.
759 // Other JsonRejection variants (syntax errors, missing
760 // content-type, schema validation) remain `invalid_json` / 400.
761 if rej.status() == StatusCode::PAYLOAD_TOO_LARGE {
762 ApiError::body_too_large(rej.body_text())
763 } else {
764 ApiError::bad_request("invalid_json", rej.body_text())
765 }
766 }
767}
768
769/// SECURITY (api S-22, api T3): the single source of truth for the
770/// sanitised, client-safe wire `message` text for every [`ExecError`]
771/// variant.
772///
773/// Pre-W4.x the synchronous error paths used `err.to_string()` verbatim
774/// as the wire `message`, which leaked server-internal state to
775/// untrusted callers in two ways:
776///
777/// * `ExecError::Wasmtime(_)` surfaced the full wasmtime error chain
778/// (host pointer addresses, host file paths, internal stack-frame
779/// names).
780/// * The structured variants (`NotFound`, `MissingExport`, `Timeout`,
781/// `ModuleMemoryTooLarge`, `ModuleTooLarge`, `CapacityExhausted`,
782/// `EpochTickerNotRunning`) embedded internal instance IDs, deadline
783/// figures, declared memory sizes, capacity counters, and export
784/// names.
785///
786/// Both the synchronous [`From<ExecError> for ApiError`] mapper and the
787/// streaming terminal-error paths (the native `/invoke-stream` writer
788/// and the OpenAI-shape SSE gateway in [`crate::openai`]) route through
789/// this function so every surface emits the identical fixed string. The
790/// original (verbose) error is logged server-side with structured fields
791/// so operators retain forensics without leaking the same state into
792/// client responses.
793pub(crate) fn sanitised_exec_error_message(err: &ExecError) -> &'static str {
794 match err {
795 ExecError::NotFound(_) => "function not found",
796 ExecError::MissingExport(_) => "requested export not found in module",
797 ExecError::Timeout(_) => "invocation deadline exceeded",
798 // ExecError::Wasmtime collapses both runtime traps and compile
799 // failures; the executor distinguishes them only when converting
800 // to TensorWasmError. For the API surface we keep a single stable
801 // opaque message — the real chain is logged at the mapping site.
802 ExecError::Wasmtime(_) => "internal execution error",
803 ExecError::ModuleMemoryTooLarge { .. } => "module declares memory above per-instance cap",
804 ExecError::CapacityExhausted { .. } => "engine instance capacity exhausted; retry later",
805 ExecError::TenantCapacityExhausted { .. } => {
806 "tenant instance quota exhausted; reduce concurrency or retry later"
807 }
808 ExecError::ModuleTooLarge { .. } => "module bytes above per-tenant cap",
809 ExecError::EpochTickerNotRunning => "engine deadline ticker not running",
810 }
811}
812
813impl From<ExecError> for ApiError {
814 fn from(err: ExecError) -> Self {
815 // The per-variant *message* text is centralised in
816 // [`sanitised_exec_error_message`] so the streaming paths emit
817 // the identical sanitised string; this impl owns the per-variant
818 // status / kind pairing and the structured server-side logging.
819 let message = sanitised_exec_error_message(&err).to_string();
820 match &err {
821 ExecError::NotFound(id) => {
822 tracing::warn!(
823 target: "tensor_wasm_api::routes",
824 instance_id = %id,
825 "exec error: instance not found",
826 );
827 ApiError {
828 status: StatusCode::NOT_FOUND,
829 kind: "instance_not_found".to_string(),
830 message,
831 }
832 }
833 ExecError::MissingExport(name) => {
834 tracing::warn!(
835 target: "tensor_wasm_api::routes",
836 export = %name,
837 "exec error: requested export missing from module",
838 );
839 ApiError {
840 status: StatusCode::BAD_REQUEST,
841 kind: "missing_export".to_string(),
842 message,
843 }
844 }
845 ExecError::Timeout(ctx) => {
846 tracing::warn!(
847 target: "tensor_wasm_api::routes",
848 instance_id = %ctx.id,
849 elapsed_ms = ctx.elapsed_ms,
850 deadline_ms = ctx.deadline_ms,
851 "exec error: invocation deadline exceeded",
852 );
853 ApiError {
854 status: StatusCode::GATEWAY_TIMEOUT,
855 kind: "invoke_timeout".to_string(),
856 message,
857 }
858 }
859 ExecError::Wasmtime(inner) => {
860 tracing::error!(
861 target: "tensor_wasm_api::routes",
862 error = ?inner,
863 error_chain = %format!("{inner:#}"),
864 "execution error mapped to 500",
865 );
866 ApiError {
867 status: StatusCode::INTERNAL_SERVER_ERROR,
868 kind: "wasmtime".to_string(),
869 message,
870 }
871 }
872 // Per mem H5 + exec S-2: module's declared linear memory exceeds
873 // the engine's per-tenant cap. Surface as 413 so clients can
874 // distinguish quota rejection from a generic compile failure.
875 // The requested / limit byte figures are operator-only state.
876 ExecError::ModuleMemoryTooLarge {
877 requested_bytes,
878 limit_bytes,
879 } => {
880 tracing::warn!(
881 target: "tensor_wasm_api::routes",
882 requested_bytes = *requested_bytes,
883 limit_bytes = *limit_bytes,
884 "exec error: module declares memory above per-instance cap",
885 );
886 ApiError {
887 status: StatusCode::PAYLOAD_TOO_LARGE,
888 kind: "module_memory_too_large".to_string(),
889 message,
890 }
891 }
892 // Per exec S-10: the engine-wide live-instance cap is
893 // saturated. 503 is the right code (the request is well-
894 // formed and would succeed once load drops) so clients with
895 // retry-with-backoff handling recover cleanly. The active /
896 // limit counters are server-internal capacity state.
897 ExecError::CapacityExhausted { active, limit } => {
898 tracing::warn!(
899 target: "tensor_wasm_api::routes",
900 active = *active,
901 limit = *limit,
902 "exec error: engine instance capacity exhausted",
903 );
904 ApiError {
905 status: StatusCode::SERVICE_UNAVAILABLE,
906 kind: "capacity_exhausted".to_string(),
907 message,
908 }
909 }
910 // Per-tenant fairness cap: this specific tenant is over its own
911 // instance quota while the shared engine budget still has room.
912 // 429 (not 503) tells the caller it is a quota condition — the
913 // remedy is to reduce *their* concurrency, not to wait for global
914 // load to drop. The tenant id / counters are server-internal
915 // (already logged at the exec rejection site).
916 ExecError::TenantCapacityExhausted { active, limit, .. } => {
917 tracing::warn!(
918 target: "tensor_wasm_api::routes",
919 active = *active,
920 limit = *limit,
921 "exec error: per-tenant instance capacity exhausted",
922 );
923 ApiError {
924 status: StatusCode::TOO_MANY_REQUESTS,
925 kind: "tenant_capacity_exhausted".to_string(),
926 message,
927 }
928 }
929 // Per B3.2: adversarial Wasm bytes that exceed the pre-compile
930 // size cap. 413 mirrors the body-too-large family. The
931 // observed length and configured cap are operator-only state.
932 ExecError::ModuleTooLarge { len, max } => {
933 tracing::warn!(
934 target: "tensor_wasm_api::routes",
935 len = *len,
936 max = *max,
937 "exec error: module bytes above per-tenant cap",
938 );
939 ApiError {
940 status: StatusCode::PAYLOAD_TOO_LARGE,
941 kind: "module_too_large".to_string(),
942 message,
943 }
944 }
945 // Per B3.2: spawn refused because the epoch ticker is down and
946 // a deadline-class bound would otherwise apply. 500 — the
947 // executor is mis-configured. The remediation hint embedded
948 // in the underlying error is operator-only.
949 ExecError::EpochTickerNotRunning => {
950 tracing::error!(
951 target: "tensor_wasm_api::routes",
952 "exec error: engine deadline ticker not running",
953 );
954 ApiError {
955 status: StatusCode::INTERNAL_SERVER_ERROR,
956 kind: "epoch_ticker_not_running".to_string(),
957 message,
958 }
959 }
960 }
961 }
962}
963
964/// Convenience alias.
965pub type ApiResult<T> = Result<T, ApiError>;
966
967// ---------------------------------------------------------------------------
968// Request / response payloads
969// ---------------------------------------------------------------------------
970
971/// Body of `POST /functions`.
972#[derive(Debug, Deserialize)]
973pub struct CreateFunctionRequest {
974 /// Tenant-supplied display name. Free-form, must be non-empty.
975 pub name: String,
976 /// Base64-encoded Wasm module bytes (standard alphabet, padded).
977 pub wasm_b64: String,
978}
979
980/// Response body of `POST /functions`.
981#[derive(Debug, Serialize)]
982pub struct CreateFunctionResponse {
983 /// Server-assigned identifier of the newly deployed function.
984 pub id: Uuid,
985}
986
987/// Body of `POST /functions/{id}/invoke` and `POST /functions/{id}/invoke-async`.
988///
989/// Both fields are optional so callers that just want the default `_start`
990/// → `main` fallback can omit them entirely (an empty `{}` body remains
991/// valid). When `args` is supplied each element is converted into a
992/// [`WasmArg`] via [`WasmArg::from_json`] before being threaded into
993/// [`TensorWasmExecutor::call_export_with_args`].
994///
995/// `#[serde(default)]` plus `deny_unknown_fields = false` (the default)
996/// keeps the schema forward-compatible: adding new optional fields later
997/// will not break clients that send the legacy `{}` body, and clients
998/// sending arbitrary extra fields are tolerated rather than rejected with
999/// 400.
1000#[derive(Debug, Default, Deserialize)]
1001pub struct InvokeRequest {
1002 /// Optional export name override. When `None`, the handler tries
1003 /// `_start` first and falls back to `main`, matching the historical
1004 /// behaviour for WASI command modules.
1005 #[serde(default)]
1006 pub export: Option<String>,
1007 /// Optional JSON-array argument list. Each element is parsed into a
1008 /// [`WasmArg`]; non-numeric elements surface as `400 invalid_args`.
1009 #[serde(default)]
1010 pub args: Vec<serde_json::Value>,
1011}
1012
1013/// Body of `POST /snapshot/save`.
1014///
1015/// Identifies the deployed function whose state is captured into a
1016/// signed snapshot blob. The captured blob's metadata is stamped with the
1017/// resolved tenant so a later `/snapshot/restore` can enforce that the
1018/// snapshot is replayed under the same tenant it was taken for.
1019#[derive(Debug, Deserialize)]
1020pub struct SnapshotSaveRequest {
1021 /// Server-assigned identifier of the function to snapshot. Must name a
1022 /// function owned by the caller's resolved tenant (cross-tenant ids are
1023 /// rejected with `403 tenant_scope_denied`).
1024 pub function_id: Uuid,
1025}
1026
1027/// Response body of `POST /snapshot/save`.
1028///
1029/// The `snapshot_b64` field carries the full signed snapshot blob
1030/// (base64, standard alphabet, padded) — the exact bytes a caller hands
1031/// back to `POST /snapshot/restore`. The HMAC trailer is part of those
1032/// bytes; the gateway never returns the signing key.
1033#[derive(Debug, Serialize)]
1034pub struct SnapshotSaveResponse {
1035 /// Function the snapshot was taken for (echoed for caller correlation).
1036 pub function_id: Uuid,
1037 /// Base64-encoded signed snapshot blob (HMAC-SHA256, v3 envelope).
1038 pub snapshot_b64: String,
1039 /// Whether the blob carries an HMAC signature. Always `true` on this
1040 /// path — the route is only reachable when a key is configured.
1041 pub signed: bool,
1042}
1043
1044/// Body of `POST /snapshot/restore`.
1045///
1046/// Carries a base64-encoded snapshot blob previously produced by
1047/// `POST /snapshot/save`. The handler verifies the blob's HMAC-SHA256
1048/// signature against the gateway's configured key before decoding any
1049/// inner payload.
1050#[derive(Debug, Deserialize)]
1051pub struct SnapshotRestoreRequest {
1052 /// Base64-encoded signed snapshot blob (standard alphabet, padded).
1053 pub snapshot_b64: String,
1054}
1055
1056/// Response body of `POST /snapshot/restore`.
1057///
1058/// Reports the verified provenance of the restored blob. Full
1059/// reconstitution of a *running* instance from the captured memory is not
1060/// yet wired (the executor exposes no restore hook); this response
1061/// confirms the HMAC-verified envelope and its metadata so the caller can
1062/// observe the round-trip succeeded.
1063#[derive(Debug, Serialize)]
1064pub struct SnapshotRestoreResponse {
1065 /// Tenant the snapshot was originally captured for, recovered from the
1066 /// HMAC-authenticated metadata.
1067 pub tenant_id: u64,
1068 /// Instance id stamped into the snapshot metadata at capture time.
1069 pub instance_id: String,
1070 /// Total uncompressed payload bytes recorded in the snapshot metadata.
1071 pub total_uncompressed_bytes: u64,
1072 /// Snapshot wire-format version that verified (`3` for the signed
1073 /// envelope this gateway writes).
1074 pub version: u32,
1075}
1076
1077// ---------------------------------------------------------------------------
1078// Handlers
1079// ---------------------------------------------------------------------------
1080
1081/// `GET /healthz` — liveness probe. Returns `{"status":"ok"}`.
1082pub async fn healthz() -> Json<serde_json::Value> {
1083 Json(serde_json::json!({ "status": "ok" }))
1084}
1085
1086/// `GET /metrics` — Prometheus text exposition.
1087///
1088/// Renders the shared [`TensorWasmMetrics`] registry into the standard text-format
1089/// (`text/plain; version=0.0.4`). Every metric registered in
1090/// `tensor_wasm_core::metrics` is exposed; counter names carry the `_total` suffix
1091/// per Prometheus convention.
1092pub async fn metrics(State(state): State<Arc<AppState>>) -> impl IntoResponse {
1093 let body = state.metrics.encode_text();
1094 (
1095 StatusCode::OK,
1096 [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
1097 body,
1098 )
1099}
1100
1101/// Validate a tenant-supplied function name before it is committed to the
1102/// in-memory registry.
1103///
1104/// Three classes of input are rejected with `400 invalid_name`:
1105///
1106/// * empty / whitespace-only names — there is nothing for an operator to
1107/// recognise the record by;
1108/// * names longer than [`MAX_FUNCTION_NAME_BYTES`] — the name is echoed back
1109/// on every `FunctionRecord` read, so an unbounded field would let a
1110/// caller anchor arbitrary memory in the registry by submitting many
1111/// records with multi-MiB names;
1112/// * names containing ASCII / Unicode control characters — these break log
1113/// readability and would let a caller smuggle NULs or escape sequences
1114/// into downstream consumers.
1115fn validate_function_name(name: &str) -> Result<(), ApiError> {
1116 if name.trim().is_empty() {
1117 return Err(ApiError::bad_request(
1118 "invalid_name",
1119 "function name must not be empty",
1120 ));
1121 }
1122 if name.len() > MAX_FUNCTION_NAME_BYTES {
1123 return Err(ApiError::bad_request(
1124 "invalid_name",
1125 "function name exceeds 256 bytes",
1126 ));
1127 }
1128 if name.chars().any(|c| c.is_control()) {
1129 return Err(ApiError::bad_request(
1130 "invalid_name",
1131 "function name contains control characters",
1132 ));
1133 }
1134 Ok(())
1135}
1136
1137/// Decode `wasm_b64`, offloading to a blocking pool when the encoded payload
1138/// is large enough that the spawn handoff is cheaper than blocking the I/O
1139/// thread. The threshold is [`BASE64_OFFLOAD_THRESHOLD`] *encoded* bytes.
1140async fn decode_wasm_b64(wasm_b64: String) -> Result<Vec<u8>, ApiError> {
1141 if wasm_b64.len() < BASE64_OFFLOAD_THRESHOLD {
1142 return BASE64
1143 .decode(wasm_b64.as_bytes())
1144 .map_err(base64_decode_error);
1145 }
1146 tokio::task::spawn_blocking(move || BASE64.decode(wasm_b64.as_bytes()))
1147 .await
1148 .map_err(|e| ApiError::internal(format!("base64 decoder panicked: {e}")))?
1149 .map_err(base64_decode_error)
1150}
1151
1152/// Map a base64 decode failure to a fixed-message `400 invalid_base64`.
1153///
1154/// L8: the underlying `base64::DecodeError` text echoes the offending
1155/// byte offset / alphabet detail of attacker-supplied input. We log the
1156/// detail server-side via `tracing` for forensics and return only a
1157/// stable, content-free message to the caller.
1158fn base64_decode_error(e: base64::DecodeError) -> ApiError {
1159 tracing::warn!(
1160 target: "tensor_wasm_api::routes",
1161 error = %e,
1162 "rejected create_function: base64 decode failed",
1163 );
1164 ApiError::bad_request("invalid_base64", "invalid base64 payload")
1165}
1166
1167/// Enforce per-tenant authorization, failing **closed** when the
1168/// [`AuthContext`](crate::rate_limit::AuthContext) extension is absent.
1169///
1170/// The production router always layers `bearer_auth`, which inserts the
1171/// `Extension<AuthContext>` every protected handler reads. If that
1172/// extension is missing the router has been wired without the auth
1173/// layer — a misconfiguration, not a legitimate anonymous request. The
1174/// old per-site pattern (`if let Some(Extension(ctx)) = auth.as_ref()`)
1175/// silently *skipped* the scope check in that case, which fails open. We
1176/// instead surface `500 internal` (and log it) so a router missing its
1177/// auth layer cannot serve cross-tenant traffic unnoticed.
1178pub(crate) fn require_authorize(
1179 auth: &Option<Extension<crate::rate_limit::AuthContext>>,
1180 tenant: TenantId,
1181) -> Result<(), ApiError> {
1182 match auth.as_ref() {
1183 Some(Extension(ctx)) => ctx.authorize_tenant(tenant),
1184 None => {
1185 tracing::error!(
1186 target: "tensor_wasm_api::routes",
1187 tenant = %tenant,
1188 "auth context missing: router misconfigured (bearer_auth layer not applied)",
1189 );
1190 Err(ApiError::internal(
1191 "auth context missing: router misconfigured",
1192 ))
1193 }
1194 }
1195}
1196
1197/// `POST /functions` — deploy a new Wasm module.
1198///
1199/// Decodes the supplied base64, runs full `wasmparser` validation, and stores
1200/// the bytes refcounted via `Arc<[u8]>` so concurrent invocations do not
1201/// reallocate. Returns `400 invalid_wasm` if validation fails.
1202#[tracing::instrument(
1203 name = "http.create_function",
1204 skip(state, auth, payload),
1205 fields(
1206 function_id = tracing::field::Empty,
1207 tenant = tracing::field::Empty,
1208 ),
1209)]
1210pub async fn create_function(
1211 State(state): State<Arc<AppState>>,
1212 tenant: Option<Extension<TenantId>>,
1213 auth: Option<Extension<crate::rate_limit::AuthContext>>,
1214 payload: Result<Json<CreateFunctionRequest>, JsonRejection>,
1215) -> ApiResult<Json<CreateFunctionResponse>> {
1216 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
1217 tracing::Span::current().record("tenant", tracing::field::display(tenant));
1218 // Tenant-scope check: a tenant-scoped token must not deploy under a
1219 // tenant outside its scope (api B1.9). Mirrors the invoke handlers'
1220 // ordering — token-scope check before any per-tenant work. Absent
1221 // AuthContext only happens in routers that bypass `bearer_auth`
1222 // entirely (ad-hoc test routers); we degrade to dev-mode wildcard.
1223 require_authorize(&auth, tenant)?;
1224 let Json(req) = payload?;
1225 validate_function_name(&req.name)?;
1226 let bytes = decode_wasm_b64(req.wasm_b64).await?;
1227 if bytes.len() < WASM_MIN_HEADER_BYTES {
1228 return Err(ApiError::bad_request(
1229 "invalid_wasm",
1230 format!(
1231 "module too short: {} bytes (minimum {WASM_MIN_HEADER_BYTES})",
1232 bytes.len()
1233 ),
1234 ));
1235 }
1236 // Full structural validation. `wasmparser::validate` walks every section
1237 // and rejects modules wasmtime would later refuse to compile, surfacing
1238 // the failure at deploy time rather than first invoke.
1239 //
1240 // For a 250 KiB module the walk takes 5-20ms of CPU; running it inline
1241 // on a Tokio reactor thread would block every other connection multiplexed
1242 // onto that worker. Offload to the blocking pool. We move `bytes` into the
1243 // closure and recover ownership via the result tuple so the downstream
1244 // `Arc::from(bytes)` does not need a second allocation.
1245 let bytes = tokio::task::spawn_blocking(move || {
1246 let validate_result = wasmparser::validate(&bytes);
1247 (bytes, validate_result)
1248 })
1249 .await
1250 .map_err(|e| ApiError::internal(format!("wasm validator panicked: {e}")))?;
1251 let (bytes, validate_result) = bytes;
1252 if let Err(e) = validate_result {
1253 // L8: the wasmparser error text describes the offending section /
1254 // offset of attacker-supplied bytes. Log it server-side for
1255 // forensics and return only a stable, content-free message.
1256 tracing::warn!(
1257 target: "tensor_wasm_api::routes",
1258 error = %e,
1259 "rejected create_function: wasm validation failed",
1260 );
1261 return Err(ApiError::bad_request("invalid_wasm", "invalid wasm module"));
1262 }
1263 let id = Uuid::new_v4();
1264 tracing::Span::current().record("function_id", tracing::field::display(id));
1265 state.functions.insert(
1266 id,
1267 FunctionRecord {
1268 id,
1269 tenant_id: tenant,
1270 name: req.name,
1271 wasm_bytes: Arc::from(bytes),
1272 created_unix_ms: now_unix_ms(),
1273 },
1274 );
1275 Ok(Json(CreateFunctionResponse { id }))
1276}
1277
1278/// `DELETE /functions/{id}` — remove a deployed function.
1279///
1280/// Returns `204 No Content` on success and `404 Not Found` if the id is
1281/// unknown.
1282#[tracing::instrument(
1283 name = "http.delete_function",
1284 skip(state, auth),
1285 fields(
1286 function_id = %id,
1287 tenant = tracing::field::Empty,
1288 ),
1289)]
1290pub async fn delete_function(
1291 State(state): State<Arc<AppState>>,
1292 Path(id): Path<Uuid>,
1293 tenant: Option<Extension<TenantId>>,
1294 auth: Option<Extension<crate::rate_limit::AuthContext>>,
1295) -> ApiResult<StatusCode> {
1296 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
1297 tracing::Span::current().record("tenant", tracing::field::display(tenant));
1298 // Token-scope check first (api B1.9): a token outside the claimed
1299 // tenant's scope must see `403 tenant_scope_denied` regardless of
1300 // registry state, so it cannot probe id existence via a 403-vs-404
1301 // split. The per-resource owner check runs only after.
1302 require_authorize(&auth, tenant)?;
1303 // Per-resource owner check: peek the record under the shard lock and
1304 // verify ownership BEFORE removing it, so a cross-tenant 403 does not
1305 // side-effect the registry.
1306 match state.functions.get(&id) {
1307 Some(entry) => {
1308 if entry.value().tenant_id != tenant {
1309 return Err(ApiError::forbidden(
1310 "tenant_scope_denied",
1311 "function belongs to a different tenant",
1312 ));
1313 }
1314 }
1315 None => return Err(ApiError::not_found(format!("function {id} not found"))),
1316 }
1317 if state.functions.remove(&id).is_some() {
1318 Ok(StatusCode::NO_CONTENT)
1319 } else {
1320 Err(ApiError::not_found(format!("function {id} not found")))
1321 }
1322}
1323
1324/// Parse a JSON-array argument list into the executor's [`WasmArg`] form.
1325///
1326/// Surfaces a `400 invalid_args` envelope on the first element that fails
1327/// conversion. The envelope's `message` includes the array index and the
1328/// offending value so a caller debugging a malformed payload can pinpoint
1329/// the problem without trial-and-error.
1330fn parse_invoke_args(raw: &[serde_json::Value]) -> Result<Vec<WasmArg>, ApiError> {
1331 // L9: cap the element count before any per-element conversion work. The
1332 // body-size limit bounds the encoded bytes but not the element count, so
1333 // an attacker could otherwise force an oversized `WasmArg` allocation
1334 // with a compact payload of many tiny scalars.
1335 if raw.len() > MAX_INVOKE_ARGS {
1336 return Err(ApiError::bad_request(
1337 "too_many_args",
1338 format!(
1339 "args list has {} elements (maximum {MAX_INVOKE_ARGS})",
1340 raw.len()
1341 ),
1342 ));
1343 }
1344 raw.iter()
1345 .enumerate()
1346 .map(|(i, v)| {
1347 WasmArg::from_json(v).map_err(|msg| {
1348 ApiError::bad_request("invalid_args", format!("args[{i}]: {msg} (value: {v})"))
1349 })
1350 })
1351 .collect()
1352}
1353
1354/// Drive the spawn → call(`_start`|`main`|custom) → terminate flow against
1355/// the supplied bytes/tenant. Shared by the synchronous and async invoke
1356/// paths so any future fix (telemetry, retries, etc.) lands in one place.
1357///
1358/// When `export_override` is `Some`, that export is invoked directly with
1359/// no fallback — the caller asked for a specific function, so a missing
1360/// export surfaces as the usual `400 missing_export`. When `None`, the
1361/// legacy WASI-command discovery applies: try `_start`, then `main`.
1362#[tracing::instrument(
1363 name = "invoke.run",
1364 skip(executor, wasm_bytes, args),
1365 fields(
1366 tenant = %tenant,
1367 function_id = %function_id,
1368 wasm_bytes_len = wasm_bytes.len(),
1369 export = tracing::field::Empty,
1370 args_len = args.len(),
1371 ),
1372)]
1373async fn run_invoke(
1374 executor: &TensorWasmExecutor,
1375 wasm_bytes: &[u8],
1376 tenant: TenantId,
1377 function_id: Uuid,
1378 export_override: Option<&str>,
1379 args: &[WasmArg],
1380) -> ApiResult<serde_json::Value> {
1381 // T33 (v0.4): attach the typed argument list to the SpawnConfig as
1382 // well as passing it explicitly to `call_export_with_args_then_terminate`.
1383 // The `SpawnConfig::args` field is the canonical carrier for the upcoming
1384 // call (see `SpawnConfig::args` doc) — wiring it here keeps the API surface
1385 // honest even though the historical explicit-pass path remains for
1386 // back-compat with embedders that drive multi-call flows.
1387 //
1388 // PERF (run_invoke arg copies): the `args` slice is already threaded
1389 // explicitly into every `call_export_with_args_then_terminate` below, so
1390 // the SpawnConfig copy is purely the canonical-carrier mirror. On the
1391 // dominant zero-arg `_start`/`main` path that copy is a pointless empty
1392 // allocation, so skip `with_args` entirely when there are no args. The
1393 // small helper centralises the build so the retry branch does not
1394 // open-code (and re-audit) the same allocation decision.
1395 let build_cfg = || {
1396 let cfg = SpawnConfig::for_tenant(tenant).with_deadline(INVOKE_DEFAULT_DEADLINE);
1397 if args.is_empty() {
1398 cfg
1399 } else {
1400 cfg.with_args(args.to_vec())
1401 }
1402 };
1403 let instance_id = executor.spawn_instance(build_cfg(), wasm_bytes).await?;
1404
1405 // api S-20 / exec orphan-instance: use `call_export_with_args_then_terminate`
1406 // so the instance is cleaned up even if our future is dropped mid-await
1407 // (e.g. by tower's `TimeoutLayer`). The previous `call_export` +
1408 // explicit `terminate` flow leaked the registry entry into
1409 // `instances` on outer cancellation, holding the wasmtime `Store`
1410 // and counting against `max_instances` until process restart.
1411 let result_value: serde_json::Value = if let Some(name) = export_override {
1412 tracing::Span::current().record("export", tracing::field::display(name));
1413 executor
1414 .call_export_with_args_then_terminate(instance_id, name, args)
1415 .await?
1416 } else {
1417 // Try `_start` (WASI command convention) first, then `main`. Anything
1418 // other than `MissingExport` from the first attempt bubbles up directly;
1419 // a missing `_start` falls through to `main`. If neither exists the
1420 // `MissingExport` from the second attempt is returned (mapped to 400).
1421 tracing::Span::current().record("export", tracing::field::display("_start|main"));
1422 match executor
1423 .call_export_with_args_then_terminate(instance_id, "_start", args)
1424 .await
1425 {
1426 Ok(v) => v,
1427 Err(ExecError::MissingExport(_)) => {
1428 // `_start` was missing AND the instance was already terminated
1429 // by the first guard. Re-spawn to try `main` — slightly more
1430 // expensive than the old "reuse the instance" flow but only
1431 // when `_start` is genuinely absent, and keeps the auto-
1432 // terminate invariant intact. Reuses `build_cfg` so the
1433 // empty-args allocation skip applies here too.
1434 let retry_id = executor.spawn_instance(build_cfg(), wasm_bytes).await?;
1435 executor
1436 .call_export_with_args_then_terminate(retry_id, "main", args)
1437 .await?
1438 }
1439 Err(other) => return Err(other.into()),
1440 }
1441 };
1442
1443 // For back-compat with the historical `{ "result": "ok" }` envelope,
1444 // collapse an empty result array to the string `"ok"`. Non-empty
1445 // result lists pass through verbatim so callers consuming an `(i32,
1446 // i32) -> i32` adder see the JSON array shape.
1447 let payload_result = match &result_value {
1448 serde_json::Value::Array(items) if items.is_empty() => {
1449 serde_json::Value::String("ok".to_string())
1450 }
1451 _ => result_value,
1452 };
1453
1454 Ok(serde_json::json!({
1455 "function_id": function_id.to_string(),
1456 "result": payload_result,
1457 }))
1458}
1459
1460/// Extract an [`InvokeRequest`] from the inbound HTTP body, treating an
1461/// absent / empty body as the default (no export override, no args).
1462///
1463/// `/invoke` historically accepted no body (api S-31); now that argument
1464/// passing is wired through we accept a body but keep the empty-body path
1465/// cheap — an empty payload short-circuits before the JSON allocator is
1466/// touched, mirroring the previous behaviour.
1467async fn read_invoke_request(
1468 payload: Result<Json<InvokeRequest>, JsonRejection>,
1469) -> ApiResult<InvokeRequest> {
1470 match payload {
1471 Ok(Json(req)) => Ok(req),
1472 Err(rej) => {
1473 // Two soft-failure cases get rewritten as the all-defaults
1474 // request so the legacy "fire-and-forget with no body" wire
1475 // contract still works:
1476 //
1477 // * `415 Unsupported Media Type` — body present but
1478 // `content-type` missing or wrong. The pre-args /invoke
1479 // accepted any body shape (it never parsed it); we keep
1480 // that surface working by treating it as defaults.
1481 // * empty inbound bytes (any rejection whose
1482 // `body_text()` is empty) — `curl -X POST /invoke`
1483 // with no `-d` body falls in here. Matches the
1484 // pre-args silent-no-op behaviour.
1485 //
1486 // Every other rejection — `400` (parse error, type error,
1487 // missing required field) and `413` (body too large) — is
1488 // forwarded through the existing `From<JsonRejection>`
1489 // mapping so the canonical envelope kicks in.
1490 if rej.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE
1491 || rej.body_text().trim().is_empty()
1492 {
1493 Ok(InvokeRequest::default())
1494 } else {
1495 Err(rej.into())
1496 }
1497 }
1498 }
1499}
1500
1501/// `POST /functions/{id}/invoke` — synchronous invocation.
1502///
1503/// Looks up the function's stored Wasm bytes, spawns a fresh instance via
1504/// the shared [`TensorWasmExecutor`] with a 30-second deadline, calls the
1505/// requested `export` (defaulting to `_start` → `main` discovery) with the
1506/// supplied `args`, then terminates the instance. Returns the
1507/// `function_id` and a JSON `result` on success; structured `ApiError`
1508/// otherwise.
1509///
1510/// The tenant id is sourced from the `X-TensorWasm-Tenant` middleware
1511/// extension; absent it defaults to `TenantId(0)`.
1512///
1513/// Body schema is [`InvokeRequest`] (both fields optional). An empty body
1514/// is treated as the all-defaults case for client compatibility with the
1515/// pre-args wire contract.
1516#[tracing::instrument(
1517 name = "http.invoke_function",
1518 skip(state, auth, payload),
1519 fields(
1520 function_id = %id,
1521 tenant = tracing::field::Empty,
1522 ),
1523)]
1524pub async fn invoke_function(
1525 State(state): State<Arc<AppState>>,
1526 Path(id): Path<Uuid>,
1527 tenant: Option<Extension<TenantId>>,
1528 auth: Option<Extension<crate::rate_limit::AuthContext>>,
1529 payload: Result<Json<InvokeRequest>, JsonRejection>,
1530) -> ApiResult<Json<serde_json::Value>> {
1531 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
1532 tracing::Span::current().record("tenant", tracing::field::display(tenant));
1533 // Tenant-scope check: reject before doing any per-tenant work (function
1534 // lookup, executor spawn, …). Absent AuthContext only happens in
1535 // configurations that bypass `bearer_auth` entirely (e.g. ad-hoc test
1536 // routers); we degrade to dev-mode wildcard there.
1537 require_authorize(&auth, tenant)?;
1538
1539 let req = read_invoke_request(payload).await?;
1540 let args = parse_invoke_args(&req.args)?;
1541
1542 // Snapshot the Wasm bytes under the DashMap shard lock, then drop the
1543 // guard before we hit any `.await`. `Arc::clone` is a single refcount
1544 // bump regardless of payload size. We also read the record's owning
1545 // tenant so the per-resource check below runs against the same guard.
1546 let (wasm_bytes, owner) = match state.functions.get(&id) {
1547 Some(entry) => (
1548 Arc::clone(&entry.value().wasm_bytes),
1549 entry.value().tenant_id,
1550 ),
1551 None => return Err(ApiError::not_found(format!("function {id} not found"))),
1552 };
1553 // Per-resource owner check (api S-IDOR): authorize_tenant above only
1554 // proves the token may address the *claimed* tenant, not that the
1555 // looked-up record belongs to it. A wildcard-scoped caller from
1556 // tenant B must not invoke tenant A's record.
1557 if owner != tenant {
1558 return Err(ApiError::forbidden(
1559 "tenant_scope_denied",
1560 "function belongs to a different tenant",
1561 ));
1562 }
1563
1564 let value = run_invoke(
1565 &state.executor,
1566 &wasm_bytes,
1567 tenant,
1568 id,
1569 req.export.as_deref(),
1570 &args,
1571 )
1572 .await?;
1573 Ok(Json(value))
1574}
1575
1576/// RAII guard pairing one `jobs_active().inc()` with exactly one `.dec()`.
1577///
1578/// Constructed at the moment a job is accepted (synchronously, before the
1579/// spawn); released on the happy path via [`Self::release`] once the job has
1580/// reached a terminal state. If the owning task instead panics before
1581/// `release` is called the `Drop` impl decrements the gauge and logs a
1582/// warning — so an unwinding spawned task does not leak a permanent
1583/// increment in the `tensor_wasm_jobs_active` series.
1584///
1585/// The handle stored is an `Arc<TensorWasmMetrics>` (not a borrowed reference)
1586/// because the guard outlives the request handler frame: it is moved into
1587/// the `tokio::spawn` body, which has a `'static` bound. The `Arc` clone is
1588/// a single refcount bump regardless of registry size.
1589struct JobsActiveGuard {
1590 metrics: Arc<TensorWasmMetrics>,
1591 decremented: bool,
1592}
1593
1594impl JobsActiveGuard {
1595 /// Increment `jobs_active` and return a guard that owns the matching
1596 /// `dec()`. Always paired one-to-one with a single `release` (happy path)
1597 /// or `Drop` (panic / early return path).
1598 fn new(metrics: Arc<TensorWasmMetrics>) -> Self {
1599 metrics.jobs_active().inc();
1600 Self {
1601 metrics,
1602 decremented: false,
1603 }
1604 }
1605
1606 /// Happy-path release. Consumes the guard, decrements the gauge, and
1607 /// marks the guard so the subsequent `Drop` is a no-op.
1608 fn release(mut self) {
1609 self.metrics.jobs_active().dec();
1610 self.decremented = true;
1611 }
1612}
1613
1614impl Drop for JobsActiveGuard {
1615 /// Panic / early-return safety net. Runs only when `release` was *not*
1616 /// called — i.e. the holding future was cancelled or the spawned task
1617 /// panicked before reaching its tail. Emits a `warn!` so an operator
1618 /// scraping logs can correlate a `jobs_active` step-down with the
1619 /// originating panic; the production happy path uses `release` and
1620 /// stays silent.
1621 fn drop(&mut self) {
1622 if !self.decremented {
1623 self.metrics.jobs_active().dec();
1624 tracing::warn!(
1625 target: "tensor_wasm_api::routes",
1626 "jobs_active gauge decremented via Drop (likely task panic or cancellation)",
1627 );
1628 }
1629 }
1630}
1631
1632/// `POST /functions/{id}/invoke-async` — fire-and-forget invocation.
1633///
1634/// Records a `Pending` [`JobRecord`], spawns the spawn/call/terminate flow
1635/// onto a Tokio task, and returns `202 Accepted` with the job id. The
1636/// task updates the registry to `Completed` (with the JSON result) or
1637/// `Failed` (with `{kind, message}`) on conclusion. Callers poll via
1638/// `GET /jobs/{id}`.
1639///
1640/// Body schema mirrors [`invoke_function`]: optional `export` /
1641/// `args` ([`InvokeRequest`]). The body is parsed synchronously before
1642/// the Tokio task spawn so `400 invalid_args` surfaces synchronously
1643/// (rather than as a `JobStatus::Failed` poll result).
1644#[tracing::instrument(
1645 name = "http.invoke_function_async",
1646 skip(state, auth, payload),
1647 fields(
1648 function_id = %id,
1649 tenant = tracing::field::Empty,
1650 job_id = tracing::field::Empty,
1651 ),
1652)]
1653pub async fn invoke_function_async(
1654 State(state): State<Arc<AppState>>,
1655 Path(id): Path<Uuid>,
1656 tenant: Option<Extension<TenantId>>,
1657 auth: Option<Extension<crate::rate_limit::AuthContext>>,
1658 payload: Result<Json<InvokeRequest>, JsonRejection>,
1659) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
1660 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
1661 tracing::Span::current().record("tenant", tracing::field::display(tenant));
1662 require_authorize(&auth, tenant)?;
1663 let req = read_invoke_request(payload).await?;
1664 let args = parse_invoke_args(&req.args)?;
1665 let export_override = req.export;
1666 let (wasm_bytes, owner) = match state.functions.get(&id) {
1667 Some(entry) => (
1668 Arc::clone(&entry.value().wasm_bytes),
1669 entry.value().tenant_id,
1670 ),
1671 None => return Err(ApiError::not_found(format!("function {id} not found"))),
1672 };
1673 // Per-resource owner check (api S-IDOR): see `invoke_function`. Runs
1674 // before the JobRecord is inserted so a cross-tenant dispatch leaves
1675 // no trace in the jobs registry.
1676 if owner != tenant {
1677 return Err(ApiError::forbidden(
1678 "tenant_scope_denied",
1679 "function belongs to a different tenant",
1680 ));
1681 }
1682
1683 // SECURITY (async-invoke resource amplification, MEDIUM): bound the
1684 // number of concurrently-outstanding async jobs BEFORE we spawn the
1685 // detached task. The HTTP request timeout / invoke concurrency layer only
1686 // bound the request future, which returns at 202 — they do not bound the
1687 // detached executor work. We gate on the live `jobs_active` gauge against
1688 // [`MAX_OUTSTANDING_ASYNC_JOBS`] and reject with the existing
1689 // `503 capacity_exhausted` shape (well-formed request, retry once load
1690 // drops) when the ceiling is already reached.
1691 //
1692 // Acquiring the [`JobsActiveGuard`] is what makes this an admission gate:
1693 // the guard's `inc()` happens here, before the spawn, and the matching
1694 // `dec()` runs on the spawned task's terminal transition (or via `Drop`
1695 // on panic), so an accepted job holds its slot for its entire lifetime.
1696 // We check-then-acquire; under a concurrent burst the gauge may briefly
1697 // overshoot the ceiling by the number of racing handlers, which is a
1698 // benign soft bound on a memory-pressure limit.
1699 if state.metrics.jobs_active().get() as usize >= MAX_OUTSTANDING_ASYNC_JOBS {
1700 tracing::warn!(
1701 target: "tensor_wasm_api::routes",
1702 active = state.metrics.jobs_active().get(),
1703 limit = MAX_OUTSTANDING_ASYNC_JOBS,
1704 "rejected invoke-async: outstanding async-job ceiling reached",
1705 );
1706 return Err(ApiError::service_unavailable(
1707 "capacity_exhausted",
1708 "outstanding async-job capacity exhausted; retry later",
1709 ));
1710 }
1711 // Account the new pending job in the gauge via a Drop-implementing
1712 // guard. The matching `.dec()` happens either through `guard.release()`
1713 // at the end of the spawned task (happy path) or through `Drop` if the
1714 // task unwinds first (panic safety net). v0.3.x emits a single series;
1715 // the v0.4 follow-up to break out per tenant lands as a Family swap in
1716 // `tensor-wasm-core/src/metrics.rs` and a label tuple here.
1717 //
1718 // Acquired BEFORE the spawn (and before the JobRecord insert) so the
1719 // gauge — which the admission gate above reads — reflects this job for
1720 // the whole window between acceptance and terminal resolution.
1721 let jobs_active_guard = JobsActiveGuard::new(Arc::clone(&state.metrics));
1722
1723 let job_id = Uuid::new_v4();
1724 tracing::Span::current().record("job_id", tracing::field::display(job_id));
1725 state.jobs.insert(
1726 job_id,
1727 JobRecord {
1728 id: job_id,
1729 function_id: id,
1730 tenant_id: tenant,
1731 status: JobStatus::Pending,
1732 result: None,
1733 created_unix_ms: now_unix_ms(),
1734 },
1735 );
1736 // SECURITY (unbounded `jobs` registry, MEDIUM): cap + evict old records
1737 // so a flood of never-polled jobs cannot pin unbounded memory.
1738 evict_jobs_if_over_cap(&state.jobs);
1739
1740 // Spawn the real invocation. The executor is cheap to clone (it's an
1741 // `Arc` internally) and the jobs map is `Arc<DashMap>`.
1742 //
1743 // The spawned task is wrapped in a dedicated `async_invoke.job` span
1744 // and instrumented so the active trace context (parent: the
1745 // `http.invoke_function_async` span we are in right now) carries
1746 // across the `tokio::spawn` boundary. Without `.instrument(...)` the
1747 // task would start a fresh root span and the executor /
1748 // snapshot-restore / dispatch spans it produces would appear
1749 // disconnected from the inbound HTTP request in the OTLP backend.
1750 let executor = Arc::clone(&state.executor);
1751 let jobs = Arc::clone(&state.jobs);
1752 let job_span = tracing::info_span!(
1753 "async_invoke.job",
1754 job_id = %job_id,
1755 function_id = %id,
1756 tenant = %tenant,
1757 );
1758 tokio::spawn(tracing::Instrument::instrument(
1759 async move {
1760 // The guard is moved into the spawned task so its lifetime
1761 // covers the entire async invocation, including any panic
1762 // unwind from `run_invoke` or the result-write block.
1763 let guard = jobs_active_guard;
1764
1765 // Test-only panic injection point: lets the gauge test
1766 // exercise the Drop-based dec without needing a wasm
1767 // module that actually traps an unwind. The probe is a
1768 // single `Relaxed` atomic load in steady state — see
1769 // [`test_hooks`] for the rationale.
1770 test_hooks::maybe_panic_for_test();
1771
1772 let outcome = run_invoke(
1773 &executor,
1774 &wasm_bytes,
1775 tenant,
1776 id,
1777 export_override.as_deref(),
1778 &args,
1779 )
1780 .await;
1781 if let Some(mut entry) = jobs.get_mut(&job_id) {
1782 match outcome {
1783 Ok(value) => {
1784 entry.status = JobStatus::Completed;
1785 entry.result = Some(value);
1786 }
1787 Err(api_err) => {
1788 let (kind, message) = api_err.to_kind_message();
1789 entry.status = JobStatus::Failed;
1790 entry.result = Some(serde_json::json!({
1791 "kind": kind,
1792 "message": message,
1793 }));
1794 }
1795 }
1796 }
1797 // Balanced release: paired with the `JobsActiveGuard::new`
1798 // before the spawn above. Runs once per terminal-state
1799 // transition regardless of outcome (Completed | Failed) so
1800 // the gauge converges back to zero on a quiescent node.
1801 // NOTE: if the jobs map no longer contains the entry (e.g.
1802 // an admin purge between insert and resolution) we still
1803 // decrement — the contract is "one `dec` per `inc`", not
1804 // "one `dec` per surviving JobRecord". If this task panics
1805 // before reaching `release`, the guard's `Drop` impl
1806 // decrements the gauge with a warn-level log instead.
1807 guard.release();
1808 },
1809 job_span,
1810 ));
1811
1812 Ok((
1813 StatusCode::ACCEPTED,
1814 Json(serde_json::json!({ "job_id": job_id.to_string() })),
1815 ))
1816}
1817
1818/// `Accept` header value that selects the Server-Sent Events response
1819/// shape on [`invoke_function_stream`]. Anything else (or absence) falls
1820/// through to the raw chunked-transfer (`application/octet-stream`)
1821/// branch.
1822pub const SSE_MIME: &str = "text/event-stream";
1823
1824/// Content-Type emitted by the chunked-transfer branch of
1825/// [`invoke_function_stream`] when the request did not negotiate SSE.
1826pub const CHUNKED_MIME: &str = "application/octet-stream";
1827
1828/// Returns `true` when the supplied `Accept` header value asks for
1829/// `text/event-stream`. Tolerates the standard `accept: a, b; q=…`
1830/// shape: any comma-separated token that starts (after trim) with
1831/// `text/event-stream` is taken as a match. Quality-value scoring is
1832/// out of scope for the scaffold; if a client lists multiple types
1833/// including SSE we serve SSE.
1834fn accept_wants_sse(headers: &HeaderMap) -> bool {
1835 headers
1836 .get(header::ACCEPT)
1837 .and_then(|v| v.to_str().ok())
1838 .is_some_and(|s| {
1839 s.split(',')
1840 .any(|part| part.trim().to_ascii_lowercase().starts_with(SSE_MIME))
1841 })
1842}
1843
1844/// Channel buffer size for the gateway-side
1845/// `mpsc::channel::<Vec<u8>>` paired with each `/invoke-stream`
1846/// invocation.
1847///
1848/// 32 frames in flight is enough to absorb a short burst from the
1849/// guest while the SSE / chunked writer drains its TCP send buffer,
1850/// but small enough to apply natural back-pressure: once the writer
1851/// stalls (slow / blocked client), the channel fills, the guest's
1852/// next `emit-chunk` blocks on `Sender::send`, and the cooperative
1853/// yield path observes the back-pressure window. See
1854/// `docs/STREAMING.md` for the threat model.
1855pub const STREAMING_CHANNEL_BUFFER: usize = 32;
1856
1857/// `POST /functions/{id}/invoke-stream` — streaming invocation
1858/// (roadmap feature #2; T34 wired this through end-to-end in v0.4).
1859///
1860/// Mirrors the body / auth surface of [`invoke_function`]. The
1861/// response shape is chosen from the request's `Accept` header:
1862///
1863/// * `Accept: text/event-stream` — Server-Sent Events. Each chunk the
1864/// guest emits via `wasi:tensor/host.emit-chunk` becomes one
1865/// `event: chunk` frame. A `keep-alive` comment is injected on idle
1866/// so intermediate proxies don't reap the connection. The stream
1867/// terminates with an `event: done` frame (`{"status":"ok"}`) on
1868/// guest success or an `event: error` frame on guest failure.
1869/// * Anything else — `Content-Type: application/octet-stream`,
1870/// chunked-transfer encoding. Each guest chunk is forwarded
1871/// verbatim as one HTTP chunk frame, followed by the same `done` /
1872/// `error` framing for terminal status.
1873///
1874/// ## Wiring
1875///
1876/// The handler builds a `tokio::sync::mpsc::channel::<Vec<u8>>` of
1877/// depth [`STREAMING_CHANNEL_BUFFER`], wraps the sender in a
1878/// [`StreamingContext`] via [`StreamingContext::with_channel`], and
1879/// passes it to the executor through
1880/// [`SpawnConfig::with_streaming`]. The executor's
1881/// `spawn_instance` path then builds a wasmtime `Linker` registering
1882/// `wasi:tensor/host.emit-chunk` / `flush` against the context so
1883/// guest emits land on the matching receiver.
1884///
1885/// The receiver is then converted into a `futures::stream::Stream`
1886/// via `stream::unfold` and either:
1887/// * wrapped in `axum::response::sse::Sse` (SSE branch) — each
1888/// `Vec<u8>` becomes one `event: chunk` frame, terminated by a
1889/// final `event: done` / `event: error`,
1890/// * collected into a `Body::from_stream` (chunked branch) — each
1891/// `Vec<u8>` becomes one HTTP chunk frame.
1892///
1893/// The guest call runs concurrently with the SSE writer via
1894/// `tokio::spawn`. A `oneshot::channel` carries the terminal status
1895/// (success / error / deadline-elapsed) so the writer can emit the
1896/// final `done` / `error` event.
1897///
1898/// ## Cancellation
1899///
1900/// If the HTTP client disconnects, axum drops the response future
1901/// which drops the SSE writer which drops the `mpsc::Receiver`. The
1902/// guest's next `emit-chunk` then returns `-3` (receiver dropped) and
1903/// the existing deadline / epoch interrupt tears the instance down.
1904/// Per `docs/STREAMING.md`, this is the documented disconnect path.
1905///
1906/// ## Security
1907///
1908/// Per `docs/STREAMING.md`, the host does NOT sanitise chunk
1909/// payloads — the bytes flow guest→client verbatim. Sanitisation
1910/// (control-byte / ANSI-escape stripping) is the client's
1911/// responsibility; the CLI's T18 sanitisation handles received text.
1912/// The host's contribution is the per-stream byte cap
1913/// ([`MAX_TOTAL_STREAM_BYTES`](tensor_wasm_wasi_gpu::streaming::MAX_TOTAL_STREAM_BYTES))
1914/// enforced inside [`StreamingContext::emit_chunk`], which bounds the
1915/// per-invocation memory footprint independent of the guest's intent.
1916///
1917/// ## Body handling
1918///
1919/// The request body matches [`InvokeRequest`] (optional `export` and
1920/// `args`), parsed the same way the synchronous [`invoke_function`]
1921/// route does. An empty body is treated as the all-defaults case so
1922/// the historical "fire-and-forget no body" wire contract still
1923/// works.
1924#[tracing::instrument(
1925 name = "http.invoke_function_stream",
1926 skip(state, auth, headers, payload),
1927 fields(
1928 function_id = %id,
1929 tenant = tracing::field::Empty,
1930 sse = tracing::field::Empty,
1931 ),
1932)]
1933pub async fn invoke_function_stream(
1934 State(state): State<Arc<AppState>>,
1935 Path(id): Path<Uuid>,
1936 tenant: Option<Extension<TenantId>>,
1937 auth: Option<Extension<crate::rate_limit::AuthContext>>,
1938 headers: HeaderMap,
1939 payload: Result<Json<InvokeRequest>, JsonRejection>,
1940) -> Result<Response, ApiError> {
1941 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
1942 tracing::Span::current().record("tenant", tracing::field::display(tenant));
1943 require_authorize(&auth, tenant)?;
1944
1945 // 404 before any negotiation work, mirroring `/invoke`.
1946 let (wasm_bytes, owner) = match state.functions.get(&id) {
1947 Some(entry) => (
1948 Arc::clone(&entry.value().wasm_bytes),
1949 entry.value().tenant_id,
1950 ),
1951 None => return Err(ApiError::not_found(format!("function {id} not found"))),
1952 };
1953 // Per-resource owner check (api S-IDOR): see `invoke_function`. A
1954 // wildcard-scoped caller from another tenant must not stream tenant
1955 // A's record.
1956 if owner != tenant {
1957 return Err(ApiError::forbidden(
1958 "tenant_scope_denied",
1959 "function belongs to a different tenant",
1960 ));
1961 }
1962
1963 let req = read_invoke_request(payload).await?;
1964 let args = parse_invoke_args(&req.args)?;
1965 let export_override = req.export;
1966
1967 // SECURITY (async-invoke resource amplification, MEDIUM): the streaming
1968 // executor call runs in a detached `tokio::spawn` below that outlives the
1969 // request future (the response streams chunks long after the handler
1970 // returns the body). Bound it against the same outstanding-job ceiling
1971 // the `invoke-async` path uses so streaming dispatch cannot amplify the
1972 // live executor-instance / task count without limit. Reject with the
1973 // shared `503 capacity_exhausted` shape; acquire the gauge guard before
1974 // the spawn and move it into the task so the slot is held for the whole
1975 // streaming lifetime and released (via `release` / `Drop`) on completion.
1976 if state.metrics.jobs_active().get() as usize >= MAX_OUTSTANDING_ASYNC_JOBS {
1977 tracing::warn!(
1978 target: "tensor_wasm_api::routes",
1979 active = state.metrics.jobs_active().get(),
1980 limit = MAX_OUTSTANDING_ASYNC_JOBS,
1981 "rejected invoke-stream: outstanding async-job ceiling reached",
1982 );
1983 return Err(ApiError::service_unavailable(
1984 "capacity_exhausted",
1985 "outstanding async-job capacity exhausted; retry later",
1986 ));
1987 }
1988 let jobs_active_guard = JobsActiveGuard::new(Arc::clone(&state.metrics));
1989
1990 let wants_sse = accept_wants_sse(&headers);
1991 tracing::Span::current().record("sse", tracing::field::display(wants_sse));
1992
1993 // Build the (sender, receiver) pair. The sender side wraps in a
1994 // `StreamingContext` that the executor will plumb into the guest
1995 // store via `SpawnConfig::with_streaming`; the receiver side stays
1996 // here and feeds the SSE / chunked-transfer response body.
1997 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(STREAMING_CHANNEL_BUFFER);
1998 let streaming = StreamingContext::with_channel(chunk_tx);
1999
2000 // `oneshot` carrying the terminal status so the SSE writer can emit
2001 // the final `event: done` / `event: error` frame after the guest
2002 // returns. `Result<(), StreamTerminalError>` distinguishes success
2003 // from failure without forcing the writer to box every error
2004 // variant.
2005 let (done_tx, done_rx) = tokio::sync::oneshot::channel::<Result<(), StreamTerminalError>>();
2006
2007 // Spawn the executor call concurrently with the SSE writer. The
2008 // guest emits land on `chunk_rx` while the writer drains; the
2009 // spawn's terminal status lands on `done_rx`.
2010 let executor = state.executor.clone();
2011 tokio::spawn(async move {
2012 // Hold the admission slot for the whole streaming invocation. Moved
2013 // into the task so its `Drop`/`release` (the matching `dec()` for the
2014 // pre-spawn `inc()`) runs when the executor work concludes — keeping
2015 // the `jobs_active` gauge an accurate outstanding-work count for the
2016 // ceiling check on the next request.
2017 let guard = jobs_active_guard;
2018 let cfg = SpawnConfig::for_tenant(tenant)
2019 .with_deadline(INVOKE_DEFAULT_DEADLINE)
2020 .with_streaming(streaming);
2021 let outcome = match executor.spawn_instance(cfg, &wasm_bytes).await {
2022 Ok(instance_id) => {
2023 let call_result = match export_override.as_deref() {
2024 Some(name) => {
2025 executor
2026 .call_export_with_args_then_terminate(instance_id, name, &args)
2027 .await
2028 }
2029 None => {
2030 // Try `_start` then `main`, matching the
2031 // synchronous /invoke fallback.
2032 match executor
2033 .call_export_with_args_then_terminate(instance_id, "_start", &args)
2034 .await
2035 {
2036 Ok(v) => Ok(v),
2037 Err(ExecError::MissingExport(_)) => {
2038 let cfg2 = SpawnConfig::for_tenant(tenant)
2039 .with_deadline(INVOKE_DEFAULT_DEADLINE);
2040 // No streaming on the retry: the first
2041 // spawn consumed the StreamingContext.
2042 // Guest emits on the retry would `-1`,
2043 // but `main` is rare on streaming
2044 // workloads — they almost always export
2045 // a custom entry point.
2046 match executor.spawn_instance(cfg2, &wasm_bytes).await {
2047 Ok(retry_id) => {
2048 executor
2049 .call_export_with_args_then_terminate(
2050 retry_id, "main", &args,
2051 )
2052 .await
2053 }
2054 Err(e) => Err(e),
2055 }
2056 }
2057 Err(other) => Err(other),
2058 }
2059 }
2060 };
2061 call_result.map(|_| ())
2062 }
2063 Err(e) => Err(e),
2064 };
2065 let terminal = match outcome {
2066 Ok(()) => Ok(()),
2067 Err(ExecError::Timeout(ctx)) => {
2068 // T36 cooperative-yield ↔ deadline integration: a
2069 // wall-clock deadline elapse surfaces as a structured
2070 // `deadline_elapsed` error event so SSE clients can
2071 // distinguish it from a generic trap.
2072 Err(StreamTerminalError {
2073 kind: "deadline_elapsed",
2074 message: format!(
2075 "instance exceeded deadline ({} ms / {} ms)",
2076 ctx.elapsed_ms, ctx.deadline_ms,
2077 ),
2078 })
2079 }
2080 // SECURITY (api S-22, api T3): route the terminal error
2081 // through the same per-variant sanitiser the synchronous
2082 // `/invoke` path uses so the SSE error frame never leaks the
2083 // raw wasmtime error chain (or other internal state) to the
2084 // client. The verbose original is logged below.
2085 Err(e) => {
2086 tracing::warn!(
2087 target: "tensor_wasm_api::routes",
2088 error = ?e,
2089 "invoke-stream terminal error mapped to sanitised SSE frame",
2090 );
2091 Err(StreamTerminalError {
2092 kind: "wasm_error",
2093 message: sanitised_exec_error_message(&e).to_string(),
2094 })
2095 }
2096 };
2097 // Best-effort: if the receiver is gone (client disconnected)
2098 // we have nothing to deliver — the response future already
2099 // dropped.
2100 let _ = done_tx.send(terminal);
2101 // Release the admission slot now the streaming work is done. Paired
2102 // with the `JobsActiveGuard::new` before the spawn; on a panic before
2103 // this point the guard's `Drop` decrements instead.
2104 guard.release();
2105 });
2106
2107 // Build the body stream. The shape is the same for both SSE and
2108 // chunked branches: drain `chunk_rx` until empty AND the
2109 // `done_rx` has fired, then emit one final terminal event.
2110 let metrics_for_writer = state.metrics.clone();
2111 let initial = StreamWriterState::Streaming(chunk_rx, done_rx);
2112 let body_stream = stream::unfold(initial, move |st| {
2113 let metrics = metrics_for_writer.clone();
2114 async move {
2115 match st {
2116 StreamWriterState::Streaming(mut rx, mut done_rx) => {
2117 // Race the next chunk against the terminal signal.
2118 // `biased` so a ready chunk is delivered before
2119 // the terminal frame fires — important when the
2120 // guest emits N chunks and immediately returns,
2121 // since both branches are then ready at once.
2122 tokio::select! {
2123 biased;
2124 maybe_chunk = rx.recv() => {
2125 match maybe_chunk {
2126 Some(c) => {
2127 metrics.streaming_chunks_emitted_total().inc();
2128 Some((
2129 StreamFrame::Chunk(c),
2130 StreamWriterState::Streaming(rx, done_rx),
2131 ))
2132 }
2133 None => {
2134 // Channel closed (sender dropped =
2135 // guest finished + executor task
2136 // completed). Pick up the terminal
2137 // status; if the oneshot is gone
2138 // too, surface a wasm_error.
2139 let terminal = done_rx.await.unwrap_or_else(|_| {
2140 Err(StreamTerminalError {
2141 kind: "wasm_error",
2142 message: "executor task dropped without signalling".to_string(),
2143 })
2144 });
2145 Some((StreamFrame::Done(terminal), StreamWriterState::Done))
2146 }
2147 }
2148 }
2149 done = &mut done_rx => {
2150 // Guest finished. Drain any in-flight
2151 // chunks before emitting the terminal
2152 // frame so the client sees every chunk
2153 // the guest successfully emitted.
2154 let terminal = done.unwrap_or_else(|_| {
2155 Err(StreamTerminalError {
2156 kind: "wasm_error",
2157 message: "executor task dropped without signalling".to_string(),
2158 })
2159 });
2160 // Try to pop one more chunk synchronously
2161 // — `try_recv` returns Empty if the buffer
2162 // is drained, in which case we emit the
2163 // terminal frame immediately.
2164 match rx.try_recv() {
2165 Ok(c) => {
2166 metrics.streaming_chunks_emitted_total().inc();
2167 Some((
2168 StreamFrame::Chunk(c),
2169 StreamWriterState::DrainOnly(rx, Some(terminal)),
2170 ))
2171 }
2172 Err(_) => Some((
2173 StreamFrame::Done(terminal),
2174 StreamWriterState::Done,
2175 )),
2176 }
2177 }
2178 }
2179 }
2180 StreamWriterState::DrainOnly(mut rx, terminal) => match rx.try_recv() {
2181 Ok(c) => {
2182 metrics.streaming_chunks_emitted_total().inc();
2183 Some((
2184 StreamFrame::Chunk(c),
2185 StreamWriterState::DrainOnly(rx, terminal),
2186 ))
2187 }
2188 Err(_) => Some((
2189 StreamFrame::Done(terminal.unwrap_or(Ok(()))),
2190 StreamWriterState::Done,
2191 )),
2192 },
2193 StreamWriterState::Done => None,
2194 }
2195 }
2196 });
2197
2198 use futures::StreamExt;
2199 if wants_sse {
2200 let sse_stream = body_stream.map(|item| {
2201 let ev = match item {
2202 StreamFrame::Chunk(bytes) => {
2203 // SSE `data:` requires UTF-8; for arbitrary bytes
2204 // we lossy-decode so the wire stays valid. Strict
2205 // byte-preservation is the chunked-transfer
2206 // branch's job. The CLI's T18 sanitisation is
2207 // responsible for control-byte handling on the
2208 // receive side.
2209 let s = String::from_utf8_lossy(&bytes).into_owned();
2210 Event::default().event("chunk").data(s)
2211 }
2212 StreamFrame::Done(Ok(())) => Event::default()
2213 .event("done")
2214 .data(serde_json::json!({"status":"ok"}).to_string()),
2215 StreamFrame::Done(Err(err)) => Event::default().event("error").data(
2216 serde_json::json!({
2217 "reason": err.kind,
2218 "message": err.message,
2219 })
2220 .to_string(),
2221 ),
2222 };
2223 Ok::<Event, std::convert::Infallible>(ev)
2224 });
2225 Ok(Sse::new(sse_stream)
2226 .keep_alive(KeepAlive::default())
2227 .into_response())
2228 } else {
2229 // Chunked-transfer branch. Each chunk is forwarded verbatim;
2230 // the terminal `done` / `error` frame is formatted as an
2231 // SSE-style line so existing clients that parse the chunked
2232 // body uniformly can detect end-of-stream without inspecting
2233 // headers.
2234 let byte_stream = body_stream.map(|item| {
2235 let bytes: axum::body::Bytes = match item {
2236 // M6 / control-byte hazard: guest chunk bytes are
2237 // forwarded verbatim here — NOT sanitised. Per the
2238 // streaming contract (see the `## Security` section on
2239 // this handler and `docs/STREAMING.md`) the host does
2240 // not strip control characters or ANSI escape
2241 // sequences, so a malicious guest can emit escape
2242 // sequences that reach a non-sanitising consumer (a raw
2243 // terminal, a log tail, a downstream pipe) and alter its
2244 // display state or smuggle in injected output. This is a
2245 // deliberate contract decision (byte-exact passthrough);
2246 // sanitisation is the client's responsibility (the CLI's
2247 // T18 layer handles received text). Do not change the
2248 // byte stream here without revisiting that contract.
2249 StreamFrame::Chunk(c) => axum::body::Bytes::from(c),
2250 StreamFrame::Done(Ok(())) => axum::body::Bytes::from(format!(
2251 "event: done\ndata: {}\n\n",
2252 serde_json::json!({"status":"ok"})
2253 )),
2254 StreamFrame::Done(Err(err)) => axum::body::Bytes::from(format!(
2255 "event: error\ndata: {}\n\n",
2256 serde_json::json!({"reason": err.kind, "message": err.message})
2257 )),
2258 };
2259 Ok::<axum::body::Bytes, std::io::Error>(bytes)
2260 });
2261 let mut resp = Response::new(Body::from_stream(byte_stream));
2262 resp.headers_mut().insert(
2263 header::CONTENT_TYPE,
2264 CHUNKED_MIME.parse().expect("static mime parses"),
2265 );
2266 Ok(resp)
2267 }
2268}
2269
2270/// Terminal status carried from the executor task to the SSE writer
2271/// via the `oneshot` channel. `kind` is the stable machine-readable
2272/// identifier (`"deadline_elapsed"`, `"wasm_error"`); `message` is
2273/// the human-readable detail.
2274#[derive(Debug, Clone)]
2275struct StreamTerminalError {
2276 kind: &'static str,
2277 message: String,
2278}
2279
2280/// One frame in the body stream the [`invoke_function_stream`] writer
2281/// emits. Lives at module scope so the `unfold` closure can name it
2282/// across `.await` points.
2283enum StreamFrame {
2284 Chunk(Vec<u8>),
2285 Done(Result<(), StreamTerminalError>),
2286}
2287
2288/// State machine driving the [`invoke_function_stream`] body
2289/// `stream::unfold`.
2290///
2291/// `Streaming` is the active phase — both the guest's chunk channel
2292/// AND the executor's terminal-status oneshot are live. `DrainOnly`
2293/// is the post-completion phase where the guest has returned but
2294/// chunks may still be buffered in the channel; we drain them before
2295/// emitting the final `done` frame so the client sees every chunk
2296/// the guest successfully forwarded. `Done` ends the stream.
2297enum StreamWriterState {
2298 Streaming(
2299 tokio::sync::mpsc::Receiver<Vec<u8>>,
2300 tokio::sync::oneshot::Receiver<Result<(), StreamTerminalError>>,
2301 ),
2302 DrainOnly(
2303 tokio::sync::mpsc::Receiver<Vec<u8>>,
2304 Option<Result<(), StreamTerminalError>>,
2305 ),
2306 Done,
2307}
2308
2309/// `GET /jobs/{id}` — poll an async invocation.
2310///
2311/// Tenant-gated (api S-32): the bearer token must address the claimed
2312/// tenant AND the job's recorded `tenant_id` must equal that tenant. The
2313/// token-scope check runs first so a caller whose token is outside the
2314/// claimed tenant's scope sees `403 tenant_scope_denied` regardless of
2315/// registry state — it cannot probe job-id existence via a 403-vs-404
2316/// split. The per-resource owner check then rejects a wildcard-scoped
2317/// caller reading another tenant's job (and its `result` payload) with
2318/// the same `403 tenant_scope_denied` shape, returning before the
2319/// `JobRecord` is serialised.
2320#[tracing::instrument(
2321 name = "http.get_job",
2322 skip(state, auth),
2323 fields(
2324 job_id = %id,
2325 tenant = tracing::field::Empty,
2326 ),
2327)]
2328pub async fn get_job(
2329 State(state): State<Arc<AppState>>,
2330 Path(id): Path<Uuid>,
2331 tenant: Option<Extension<TenantId>>,
2332 auth: Option<Extension<crate::rate_limit::AuthContext>>,
2333) -> ApiResult<Json<JobRecord>> {
2334 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
2335 tracing::Span::current().record("tenant", tracing::field::display(tenant));
2336 // Token-scope check BEFORE the per-resource owner check (api S-32):
2337 // an out-of-scope token must not be able to distinguish a 403 (job
2338 // exists, wrong tenant) from a 404 (job unknown).
2339 require_authorize(&auth, tenant)?;
2340 // PERF (get_job deep clone under guard): the previous code cloned the
2341 // whole `JobRecord` — including the potentially large `result`
2342 // `serde_json::Value` — while still holding the DashMap shard guard,
2343 // serialising every concurrent reader/writer of that shard for the
2344 // duration of the deep clone. We now do only the cheap owner-check copy
2345 // (a `u64` tenant id) under the guard, drop it, and clone the record
2346 // outside the lock so the shard is contended for the minimum window.
2347 let owner = match state.jobs.get(&id) {
2348 Some(rec) => rec.value().tenant_id,
2349 None => return Err(ApiError::not_found(format!("job {id} not found"))),
2350 };
2351 // Per-resource owner check: reject before serialising the record so a
2352 // cross-tenant caller never observes the job's status / result /
2353 // function_id.
2354 if owner != tenant {
2355 return Err(ApiError::forbidden(
2356 "tenant_scope_denied",
2357 "job belongs to a different tenant",
2358 ));
2359 }
2360 // Re-fetch and clone outside the owner-check guard above. A concurrent
2361 // eviction between the two lookups is benign: it can only turn a hit into
2362 // a 404, which is a legitimate response for a since-evicted job.
2363 match state.jobs.get(&id) {
2364 Some(rec) => Ok(Json(rec.clone())),
2365 None => Err(ApiError::not_found(format!("job {id} not found"))),
2366 }
2367}
2368
2369// ---------------------------------------------------------------------------
2370// Snapshot save / restore (M5)
2371// ---------------------------------------------------------------------------
2372
2373/// `POST /snapshot/save` — capture a deployed function into a signed
2374/// snapshot blob.
2375///
2376/// ## Auth posture
2377///
2378/// Mounted on the protected stack (bearer auth + tenant scope + rate
2379/// limit + audit), exactly like the function-mutating routes. The handler
2380/// runs the same two-stage tenant gate the other resource handlers use:
2381///
2382/// 1. `authorize_tenant` against the bearer token's scope — an out-of-scope
2383/// token sees `403 tenant_scope_denied` before any registry lookup, so
2384/// it cannot probe function-id existence via a 403-vs-404 split.
2385/// 2. a per-resource owner check against the looked-up
2386/// [`FunctionRecord::tenant_id`] — a wildcard-scoped caller from another
2387/// tenant cannot snapshot tenant A's function.
2388///
2389/// ## HMAC wiring (M5)
2390///
2391/// The snapshot blob is signed with the operator-configured
2392/// `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` (threaded in via
2393/// [`AppState::app_config`]). When no key is configured the route returns
2394/// `503 snapshot_signing_not_configured`, mirroring the kernel-registry
2395/// posture — the gateway will not silently emit an unsigned blob under a
2396/// route that promises a signed one.
2397///
2398/// ## What is captured
2399///
2400/// The function's deployed Wasm module bytes are captured into the
2401/// snapshot's `wasm_memory` blob via
2402/// [`tensor_wasm_snapshot::writer::SnapshotWriter`]. This is the working
2403/// save/restore-of-bytes layer with end-to-end HMAC signing. Capturing a
2404/// *live running instance's* linear / GPU memory needs a
2405/// `TensorWasmExecutor` capture hook that does not exist yet; that
2406/// capability is intentionally out of scope here (see the module status in
2407/// [`crate::config`]). The blob produced here round-trips through
2408/// `/snapshot/restore`.
2409#[tracing::instrument(
2410 name = "http.snapshot_save",
2411 skip(state, auth, payload),
2412 fields(
2413 function_id = tracing::field::Empty,
2414 tenant = tracing::field::Empty,
2415 ),
2416)]
2417pub async fn snapshot_save(
2418 State(state): State<Arc<AppState>>,
2419 tenant: Option<Extension<TenantId>>,
2420 auth: Option<Extension<crate::rate_limit::AuthContext>>,
2421 payload: Result<Json<SnapshotSaveRequest>, JsonRejection>,
2422) -> ApiResult<Json<SnapshotSaveResponse>> {
2423 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
2424 tracing::Span::current().record("tenant", tracing::field::display(tenant));
2425 // Token-scope check first (mirrors create/delete/invoke): an
2426 // out-of-scope token must be rejected before any registry lookup.
2427 require_authorize(&auth, tenant)?;
2428
2429 // The route is only useful with a signing key; refuse early with the
2430 // same 503-config shape the kernel routes use so a client can detect
2431 // the feature being disabled without a key leak or a misleading 200.
2432 let key = match state.app_config.snapshot_hmac_key {
2433 Some(k) => k,
2434 None => {
2435 return Err(ApiError::service_unavailable(
2436 "snapshot_signing_not_configured",
2437 "snapshot signing key is not configured; set \
2438 TENSOR_WASM_API_SNAPSHOT_HMAC_KEY to enable /snapshot/save",
2439 ));
2440 }
2441 };
2442
2443 let Json(req) = payload?;
2444 tracing::Span::current().record("function_id", tracing::field::display(req.function_id));
2445
2446 // Snapshot the Wasm bytes + owning tenant under the shard lock, then
2447 // drop the guard before any blocking / await work.
2448 let (wasm_bytes, owner) = match state.functions.get(&req.function_id) {
2449 Some(entry) => (
2450 Arc::clone(&entry.value().wasm_bytes),
2451 entry.value().tenant_id,
2452 ),
2453 None => {
2454 return Err(ApiError::not_found(format!(
2455 "function {} not found",
2456 req.function_id
2457 )))
2458 }
2459 };
2460 // Per-resource owner check (api S-IDOR): the token-scope gate above
2461 // only proves the caller may address the *claimed* tenant, not that
2462 // the looked-up record belongs to it.
2463 if owner != tenant {
2464 return Err(ApiError::forbidden(
2465 "tenant_scope_denied",
2466 "function belongs to a different tenant",
2467 ));
2468 }
2469
2470 // Stamp the instance id from the function id's low 64 bits so the
2471 // restore side can correlate the blob back to a function. The capture
2472 // itself is CPU-bound (bincode + zstd) over potentially large module
2473 // bytes, so run it on the blocking pool to keep the reactor free.
2474 let instance_id = InstanceId(req.function_id.as_u128() & u128::from(u64::MAX));
2475 let snapshot_bytes = tokio::task::spawn_blocking(move || {
2476 use tensor_wasm_snapshot::writer::{InstanceState, SnapshotWriter};
2477 // `with_legacy_envelope` pins the inline v3 (HMAC-SHA256-signed)
2478 // wire format so the api crate does not depend on the artifact
2479 // store; the reader on `/snapshot/restore` is configured the same
2480 // way (`with_hmac_sha256_key`).
2481 SnapshotWriter::new()
2482 .with_hmac_sha256_key(key)
2483 .with_legacy_envelope()
2484 .capture(InstanceState {
2485 tenant_id: tenant,
2486 instance_id,
2487 wasm_memory: &wasm_bytes,
2488 gpu_memory: &[],
2489 registers: &[],
2490 })
2491 })
2492 .await
2493 .map_err(|e| ApiError::internal(format!("snapshot capture task panicked: {e}")))?
2494 .map_err(|e| {
2495 // The snapshot crate's `Display` (Serialization variant) is
2496 // operator-facing and key-free; log server-side and return a
2497 // stable, content-light message to the caller.
2498 tracing::error!(
2499 target: "tensor_wasm_api::routes",
2500 error = %e,
2501 "snapshot capture failed",
2502 );
2503 ApiError::internal("snapshot capture failed")
2504 })?;
2505
2506 let snapshot_b64 = BASE64.encode(&snapshot_bytes);
2507 Ok(Json(SnapshotSaveResponse {
2508 function_id: req.function_id,
2509 snapshot_b64,
2510 signed: true,
2511 }))
2512}
2513
2514/// `POST /snapshot/restore` — verify and decode a signed snapshot blob.
2515///
2516/// ## Auth posture
2517///
2518/// Same protected-stack posture as [`snapshot_save`]: bearer auth + tenant
2519/// scope + rate limit + audit. The handler runs `authorize_tenant` against
2520/// the caller's token scope, then — after HMAC verification recovers the
2521/// snapshot's captured tenant — enforces a per-resource owner check: the
2522/// blob's `metadata.tenant_id` must equal the caller's resolved tenant, so
2523/// a wildcard-scoped caller from tenant B cannot restore a blob captured
2524/// for tenant A (`403 tenant_scope_denied`).
2525///
2526/// ## HMAC wiring (M5)
2527///
2528/// Verification uses the operator-configured
2529/// `TENSOR_WASM_API_SNAPSHOT_HMAC_KEY` via
2530/// [`SnapshotReader::with_hmac_sha256_key`](tensor_wasm_snapshot::reader::SnapshotReader::with_hmac_sha256_key)
2531/// and [`SnapshotReader::require_signature`](tensor_wasm_snapshot::reader::SnapshotReader::require_signature):
2532/// the reader rejects any blob whose HMAC does not verify, and — because
2533/// the gateway always opts into `require_signature` on this path — refuses
2534/// unsigned v2 blobs outright. A wrong / missing key or a tampered blob
2535/// surfaces as `403 snapshot_signature_invalid`. When no key is configured
2536/// the route returns `503 snapshot_signing_not_configured`.
2537///
2538/// The reader's [`with_max_decompressed`](tensor_wasm_snapshot::reader::SnapshotReader::with_max_decompressed)
2539/// hardening uses the crate default (256 MiB), bounding restore-time memory
2540/// pressure from a zip-bomb payload.
2541///
2542/// ## What is restored
2543///
2544/// This handler restores and authenticates the snapshot *envelope* and
2545/// returns its verified provenance. Reconstituting a *running* instance
2546/// from the captured memory needs a `TensorWasmExecutor` restore hook that
2547/// does not exist yet; that capability surfaces `501 not_implemented` when
2548/// requested. Verifying the blob and recovering its metadata — the part
2549/// that exercises the HMAC key end-to-end — works today.
2550#[tracing::instrument(
2551 name = "http.snapshot_restore",
2552 skip(state, auth, payload),
2553 fields(tenant = tracing::field::Empty),
2554)]
2555pub async fn snapshot_restore(
2556 State(state): State<Arc<AppState>>,
2557 tenant: Option<Extension<TenantId>>,
2558 auth: Option<Extension<crate::rate_limit::AuthContext>>,
2559 payload: Result<Json<SnapshotRestoreRequest>, JsonRejection>,
2560) -> ApiResult<Json<SnapshotRestoreResponse>> {
2561 let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
2562 tracing::Span::current().record("tenant", tracing::field::display(tenant));
2563 require_authorize(&auth, tenant)?;
2564
2565 let key = match state.app_config.snapshot_hmac_key {
2566 Some(k) => k,
2567 None => {
2568 return Err(ApiError::service_unavailable(
2569 "snapshot_signing_not_configured",
2570 "snapshot signing key is not configured; set \
2571 TENSOR_WASM_API_SNAPSHOT_HMAC_KEY to enable /snapshot/restore",
2572 ));
2573 }
2574 };
2575
2576 let Json(req) = payload?;
2577 // Decode the base64 envelope. Reuse the dedicated invalid_base64
2578 // mapping so the byte-offset detail of attacker input is logged, not
2579 // echoed.
2580 let snapshot_bytes = BASE64
2581 .decode(req.snapshot_b64.as_bytes())
2582 .map_err(base64_decode_error)?;
2583
2584 // Hardening posture (consumes `snapshot_require_signature`):
2585 //
2586 // `require_signature()` is applied unconditionally on this path. The
2587 // gateway only ever *writes* the signed v3 envelope (see
2588 // [`snapshot_save`]), so accepting an unsigned v2 blob on restore would
2589 // be a strip-the-trailer downgrade primitive. The operator-facing
2590 // `TENSOR_WASM_API_SNAPSHOT_REQUIRE_SIGNATURE` knob is the documented
2591 // surface for this stricter posture; here it is enforced as the
2592 // hardened default. We still read the configured flag so that an
2593 // operator who explicitly set it gets a confirming log line (and so a
2594 // future relaxation of the unconditional require has the value in
2595 // hand) — the knob is genuinely consumed, not inert.
2596 if state.app_config.snapshot_require_signature {
2597 tracing::debug!(
2598 target: "tensor_wasm_api::routes",
2599 "snapshot restore: require-signature explicitly enabled by operator",
2600 );
2601 }
2602 // `with_max_decompressed` is left at the crate default
2603 // (`limits::MAX_DECOMPRESSED_BYTES`, 256 MiB) — the standard zip-bomb
2604 // ceiling that bounds restore-time memory pressure. Verify HMAC +
2605 // decode on the blocking pool since zstd + bincode are CPU-bound.
2606 let restored = tokio::task::spawn_blocking(move || {
2607 use tensor_wasm_snapshot::reader::SnapshotReader;
2608 SnapshotReader::new()
2609 .with_hmac_sha256_key(key)
2610 .require_signature()
2611 .restore(&snapshot_bytes)
2612 })
2613 .await
2614 .map_err(|e| ApiError::internal(format!("snapshot restore task panicked: {e}")))?;
2615
2616 let snapshot = match restored {
2617 Ok(s) => s,
2618 Err(e) => {
2619 // Any verification / structural failure (wrong key, missing
2620 // signature, tampered bytes, oversized decompress) is surfaced
2621 // as a single 403 so a caller cannot use the error text as a
2622 // decode oracle. The detailed snapshot-crate message is logged
2623 // server-side for operators.
2624 tracing::warn!(
2625 target: "tensor_wasm_api::routes",
2626 error = %e,
2627 "snapshot restore rejected (signature / structural verification failed)",
2628 );
2629 return Err(ApiError::forbidden(
2630 "snapshot_signature_invalid",
2631 "snapshot signature verification failed or blob is malformed",
2632 ));
2633 }
2634 };
2635
2636 // Per-resource owner check: the HMAC-authenticated metadata records the
2637 // tenant the blob was captured for. A caller must not restore another
2638 // tenant's blob, even with a valid signature (the key is per-deployment,
2639 // not per-tenant), so reject a cross-tenant restore with the same
2640 // `tenant_scope_denied` shape the other handlers use.
2641 if snapshot.metadata.tenant_id != tenant {
2642 return Err(ApiError::forbidden(
2643 "tenant_scope_denied",
2644 "snapshot was captured for a different tenant",
2645 ));
2646 }
2647
2648 Ok(Json(SnapshotRestoreResponse {
2649 tenant_id: snapshot.metadata.tenant_id.0,
2650 instance_id: snapshot.metadata.instance_id.to_string(),
2651 total_uncompressed_bytes: snapshot.metadata.total_uncompressed_bytes,
2652 version: snapshot.version,
2653 }))
2654}
2655
2656// ---------------------------------------------------------------------------
2657// Test-only hooks
2658// ---------------------------------------------------------------------------
2659
2660/// Test-only fault-injection probes for [`invoke_function_async`].
2661///
2662/// Exposed as `#[doc(hidden)] pub` so the in-tree integration test
2663/// (`tests/jobs_active_gauge.rs`) can arm the probes. The steady-state cost
2664/// is one `Relaxed` atomic load per async-invoke dispatch — negligible next
2665/// to the executor `.spawn_instance` walk that immediately follows — so the
2666/// probes are compiled into all builds rather than feature-gated. Outside
2667/// the crate's test surface there is no public API to *set* the flag, so
2668/// the production code path is unreachable.
2669#[doc(hidden)]
2670pub mod test_hooks {
2671 use std::sync::atomic::{AtomicBool, Ordering};
2672
2673 /// When `true`, the next entry to `maybe_panic_for_test` panics and
2674 /// resets the flag.
2675 static PANIC_NEXT_INVOKE: AtomicBool = AtomicBool::new(false);
2676
2677 /// Arm the panic-injection probe. Returns a drop-guard that disarms it
2678 /// on scope exit so a failing assertion does not leak the flag into a
2679 /// neighbouring test.
2680 pub fn arm_panic() -> ArmedPanic {
2681 PANIC_NEXT_INVOKE.store(true, Ordering::SeqCst);
2682 ArmedPanic
2683 }
2684
2685 /// RAII disarm. Independent of whether the panic actually fired.
2686 pub struct ArmedPanic;
2687
2688 impl Drop for ArmedPanic {
2689 fn drop(&mut self) {
2690 PANIC_NEXT_INVOKE.store(false, Ordering::SeqCst);
2691 }
2692 }
2693
2694 /// Probe called from the spawned async-invoke task. Panics (and clears
2695 /// the flag) when armed; otherwise a noop. Steady-state production
2696 /// cost: one `Relaxed` atomic load.
2697 #[inline]
2698 pub(crate) fn maybe_panic_for_test() {
2699 // `Relaxed` is the right ordering here: there is no other state
2700 // we need to synchronise against the flag flip. The
2701 // compare-exchange upgrades to `SeqCst` only on the cold path
2702 // when the probe actually fires, which is fine for tests.
2703 if PANIC_NEXT_INVOKE.load(Ordering::Relaxed)
2704 && PANIC_NEXT_INVOKE
2705 .compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
2706 .is_ok()
2707 {
2708 panic!(
2709 "test_hooks::maybe_panic_for_test: deliberate panic for JobsActiveGuard Drop test"
2710 );
2711 }
2712 }
2713}
2714
2715// ---------------------------------------------------------------------------
2716// Tests
2717// ---------------------------------------------------------------------------
2718
2719#[cfg(test)]
2720mod tests {
2721 use super::*;
2722
2723 #[test]
2724 fn api_error_constructors_carry_status() {
2725 let bad = ApiError::bad_request("invalid_name", "x");
2726 assert_eq!(bad.status, StatusCode::BAD_REQUEST);
2727 assert_eq!(bad.kind, "invalid_name");
2728
2729 let nf = ApiError::not_found("missing");
2730 assert_eq!(nf.status, StatusCode::NOT_FOUND);
2731 assert_eq!(nf.kind, "not_found");
2732
2733 let oops = ApiError::internal("boom");
2734 assert_eq!(oops.status, StatusCode::INTERNAL_SERVER_ERROR);
2735 assert_eq!(oops.kind, "internal");
2736 }
2737
2738 #[test]
2739 fn app_state_default_is_empty() {
2740 let s = AppState::default();
2741 assert_eq!(s.functions.len(), 0);
2742 assert_eq!(s.jobs.len(), 0);
2743 // The default metrics registry exposes every TensorWasm metric name.
2744 let m = s.metrics.encode_text();
2745 assert!(m.contains("tensor_wasm_active_instances"), "got: {m}");
2746 }
2747
2748 #[test]
2749 fn app_state_with_metrics_swaps_handle() {
2750 let custom = Arc::new(TensorWasmMetrics::new());
2751 custom.kernel_dispatches_total().inc();
2752 let s = AppState::default().with_metrics(custom.clone());
2753 // The state's metrics handle reflects the externally observed
2754 // counter increment — proving the handle was actually swapped in.
2755 let text = s.metrics.encode_text();
2756 assert!(
2757 text.contains("tensor_wasm_kernel_dispatches_total 1"),
2758 "got: {text}"
2759 );
2760 }
2761
2762 #[test]
2763 fn app_state_try_new_constructs() {
2764 let s = AppState::try_new().expect("default engine builds on this host");
2765 assert_eq!(s.functions.len(), 0);
2766 assert_eq!(s.jobs.len(), 0);
2767 }
2768
2769 #[test]
2770 fn function_record_skips_wasm_bytes_on_wire() {
2771 let rec = FunctionRecord {
2772 id: Uuid::nil(),
2773 tenant_id: TenantId(0),
2774 name: "n".to_string(),
2775 wasm_bytes: Arc::from(vec![1u8, 2, 3]),
2776 created_unix_ms: 0,
2777 };
2778 let v = serde_json::to_value(&rec).unwrap();
2779 assert!(v.get("wasm_bytes").is_none(), "wasm_bytes leaked: {v}");
2780 }
2781
2782 #[tokio::test]
2783 async fn exec_error_compile_maps_to_wasmtime_500() {
2784 // Compile failures surface from the executor as `ExecError::Wasmtime`
2785 // (the executor does not split traps vs. compile errors at the
2786 // `ExecError` layer — that distinction is only made when converting
2787 // into `TensorWasmError`). The API layer therefore maps them to a generic
2788 // 500 `wasmtime` envelope; the real defence against invalid Wasm is
2789 // `wasmparser::validate` at deploy time, which returns 400 long
2790 // before this path is reached.
2791 let engine = std::sync::Arc::new(TensorWasmEngine::new().expect("engine"));
2792 let exec = TensorWasmExecutor::new(engine);
2793 let err = exec
2794 .spawn_instance(SpawnConfig::for_tenant(TenantId(0)), b"not wasm")
2795 .await
2796 .expect_err("compile fails");
2797 assert!(matches!(err, ExecError::Wasmtime(_)));
2798 let api: ApiError = err.into();
2799 assert_eq!(api.status, StatusCode::INTERNAL_SERVER_ERROR);
2800 assert_eq!(api.kind, "wasmtime");
2801 }
2802
2803 #[test]
2804 fn jobs_active_guard_release_decrements_exactly_once() {
2805 // Happy path: `new` + `release` is a balanced inc/dec, no warn.
2806 let metrics = Arc::new(TensorWasmMetrics::new());
2807 assert_eq!(metrics.jobs_active().get(), 0);
2808 let g = JobsActiveGuard::new(Arc::clone(&metrics));
2809 assert_eq!(metrics.jobs_active().get(), 1);
2810 g.release();
2811 assert_eq!(metrics.jobs_active().get(), 0);
2812 }
2813
2814 #[test]
2815 fn jobs_active_guard_drop_decrements_on_panic_path() {
2816 // Panic-safety path: a guard dropped without `release` (i.e. the
2817 // task unwound) still decrements the gauge so the series does not
2818 // leak a permanent increment. We simulate the unwind by letting
2819 // the guard fall out of scope without calling `release`.
2820 let metrics = Arc::new(TensorWasmMetrics::new());
2821 {
2822 let _g = JobsActiveGuard::new(Arc::clone(&metrics));
2823 assert_eq!(metrics.jobs_active().get(), 1);
2824 // No `release()` here — simulates a panic before the explicit
2825 // dec point. The `Drop` impl is the safety net.
2826 }
2827 assert_eq!(
2828 metrics.jobs_active().get(),
2829 0,
2830 "Drop must decrement the gauge when release was not called"
2831 );
2832 }
2833
2834 #[test]
2835 fn exec_error_timeout_maps_to_invoke_timeout() {
2836 use tensor_wasm_core::types::InstanceId;
2837 use tensor_wasm_exec::executor::TimeoutContext;
2838 let err = ExecError::Timeout(TimeoutContext {
2839 id: InstanceId(7),
2840 elapsed_ms: 1000,
2841 deadline_ms: 500,
2842 });
2843 let api: ApiError = err.into();
2844 assert_eq!(api.status, StatusCode::GATEWAY_TIMEOUT);
2845 assert_eq!(api.kind, "invoke_timeout");
2846 // SECURITY (api T3): message must NOT leak the instance id or
2847 // the elapsed / deadline figures — those are server-internal.
2848 assert_eq!(api.message, "invocation deadline exceeded");
2849 assert!(
2850 !api.message.contains("I#7"),
2851 "leaked instance id: {}",
2852 api.message,
2853 );
2854 assert!(
2855 !api.message.contains("1000") && !api.message.contains("500"),
2856 "leaked timing figures: {}",
2857 api.message,
2858 );
2859 }
2860
2861 // -----------------------------------------------------------------
2862 // SECURITY (api T3): `From<ExecError> for ApiError` previously used
2863 // `err.to_string()` verbatim for several variants, leaking internal
2864 // instance IDs, deadlines, quotas, capacity counters, and export
2865 // names to untrusted callers. The tests below pin the per-variant
2866 // wire `message` to a fixed, stable string and assert that none of
2867 // the structured fields appear in the response body.
2868 // -----------------------------------------------------------------
2869
2870 #[test]
2871 fn exec_error_not_found_maps_to_stable_404_message() {
2872 use tensor_wasm_core::types::InstanceId;
2873 let err = ExecError::NotFound(InstanceId(42));
2874 let api: ApiError = err.into();
2875 assert_eq!(api.status, StatusCode::NOT_FOUND);
2876 assert_eq!(api.kind, "instance_not_found");
2877 assert_eq!(api.message, "function not found");
2878 assert!(
2879 !api.message.contains("42") && !api.message.contains("I#"),
2880 "leaked instance id: {}",
2881 api.message,
2882 );
2883 }
2884
2885 #[test]
2886 fn exec_error_missing_export_maps_to_stable_400_message() {
2887 let err = ExecError::MissingExport("super_secret_internal_symbol".to_string());
2888 let api: ApiError = err.into();
2889 assert_eq!(api.status, StatusCode::BAD_REQUEST);
2890 assert_eq!(api.kind, "missing_export");
2891 assert_eq!(api.message, "requested export not found in module");
2892 // The export name is attacker-controlled but echoing it back
2893 // expands the attack surface (XSS-via-error, info-leak about
2894 // which symbols the module exports). The wire message must
2895 // be content-free.
2896 assert!(
2897 !api.message.contains("super_secret_internal_symbol"),
2898 "leaked export name: {}",
2899 api.message,
2900 );
2901 }
2902
2903 #[test]
2904 fn exec_error_module_memory_too_large_maps_to_stable_413_message() {
2905 let err = ExecError::ModuleMemoryTooLarge {
2906 requested_bytes: 4_294_967_296,
2907 limit_bytes: 67_108_864,
2908 };
2909 let api: ApiError = err.into();
2910 assert_eq!(api.status, StatusCode::PAYLOAD_TOO_LARGE);
2911 assert_eq!(api.kind, "module_memory_too_large");
2912 assert_eq!(api.message, "module declares memory above per-instance cap",);
2913 assert!(
2914 !api.message.contains("4294967296") && !api.message.contains("67108864"),
2915 "leaked byte figures: {}",
2916 api.message,
2917 );
2918 }
2919
2920 #[test]
2921 fn exec_error_capacity_exhausted_maps_to_stable_503_message() {
2922 let err = ExecError::CapacityExhausted {
2923 active: 257,
2924 limit: 256,
2925 };
2926 let api: ApiError = err.into();
2927 assert_eq!(api.status, StatusCode::SERVICE_UNAVAILABLE);
2928 assert_eq!(api.kind, "capacity_exhausted");
2929 assert_eq!(
2930 api.message,
2931 "engine instance capacity exhausted; retry later",
2932 );
2933 assert!(
2934 !api.message.contains("257") && !api.message.contains("256"),
2935 "leaked capacity figures: {}",
2936 api.message,
2937 );
2938 }
2939
2940 #[test]
2941 fn exec_error_module_too_large_maps_to_stable_413_message() {
2942 let err = ExecError::ModuleTooLarge {
2943 len: 16_777_217,
2944 max: 16_777_216,
2945 };
2946 let api: ApiError = err.into();
2947 assert_eq!(api.status, StatusCode::PAYLOAD_TOO_LARGE);
2948 assert_eq!(api.kind, "module_too_large");
2949 assert_eq!(api.message, "module bytes above per-tenant cap");
2950 assert!(
2951 !api.message.contains("16777217") && !api.message.contains("16777216"),
2952 "leaked size figures: {}",
2953 api.message,
2954 );
2955 }
2956
2957 /// Helper: build a `JobRecord` with the given status + creation stamp.
2958 /// `function_id` / `tenant` are irrelevant to the eviction policy so they
2959 /// are fixed.
2960 fn job_with(status: JobStatus, created_unix_ms: u64) -> (Uuid, JobRecord) {
2961 let id = Uuid::new_v4();
2962 (
2963 id,
2964 JobRecord {
2965 id,
2966 function_id: Uuid::nil(),
2967 tenant_id: TenantId(0),
2968 status,
2969 result: None,
2970 created_unix_ms,
2971 },
2972 )
2973 }
2974
2975 #[test]
2976 fn evict_jobs_noop_when_under_cap() {
2977 // Under the cap: the map is left untouched.
2978 let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
2979 for i in 0..10u64 {
2980 let (id, rec) = job_with(JobStatus::Completed, i);
2981 jobs.insert(id, rec);
2982 }
2983 evict_jobs_if_over_cap(&jobs);
2984 assert_eq!(jobs.len(), 10, "no eviction expected under the cap");
2985 }
2986
2987 #[test]
2988 fn evict_jobs_prefers_terminal_oldest_first() {
2989 // Force the cap down to a tiny value via a local map populated past
2990 // MAX_JOB_RECORDS would be wasteful, so exercise the policy directly
2991 // by inserting MAX_JOB_RECORDS + N records and asserting the survivors.
2992 let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
2993
2994 // One Pending job (must survive — it is in flight) plus enough
2995 // Completed jobs to push two over the cap. Timestamps ascending so we
2996 // can assert the oldest terminal records are the ones dropped.
2997 let (pending_id, pending) = job_with(JobStatus::Pending, 0);
2998 jobs.insert(pending_id, pending);
2999
3000 let mut completed_ids_by_age: Vec<Uuid> = Vec::new();
3001 // total terminal = MAX_JOB_RECORDS + 1 (so map = cap + 2 with the
3002 // pending one), forcing exactly 2 evictions.
3003 for i in 0..(MAX_JOB_RECORDS as u64 + 1) {
3004 let (id, rec) = job_with(JobStatus::Completed, i + 1);
3005 completed_ids_by_age.push(id);
3006 jobs.insert(id, rec);
3007 }
3008 assert_eq!(jobs.len(), MAX_JOB_RECORDS + 2);
3009
3010 evict_jobs_if_over_cap(&jobs);
3011 assert_eq!(jobs.len(), MAX_JOB_RECORDS);
3012
3013 // The Pending job must survive — terminal records are evicted first.
3014 assert!(
3015 jobs.contains_key(&pending_id),
3016 "in-flight Pending job must not be evicted while terminal records exist",
3017 );
3018 // The two oldest Completed records (smallest created_unix_ms) are gone.
3019 assert!(
3020 !jobs.contains_key(&completed_ids_by_age[0]),
3021 "oldest terminal record should be evicted",
3022 );
3023 assert!(
3024 !jobs.contains_key(&completed_ids_by_age[1]),
3025 "second-oldest terminal record should be evicted",
3026 );
3027 // A newer terminal record survives.
3028 assert!(
3029 jobs.contains_key(&completed_ids_by_age[completed_ids_by_age.len() - 1]),
3030 "newest terminal record should survive",
3031 );
3032 }
3033
3034 #[test]
3035 fn evict_jobs_falls_back_to_oldest_when_all_pending() {
3036 // Pathological all-Pending flood: no terminal records to evict, so the
3037 // policy falls back to dropping the oldest records of any status to
3038 // keep the map bounded.
3039 let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
3040 let mut ids_by_age: Vec<Uuid> = Vec::new();
3041 for i in 0..(MAX_JOB_RECORDS as u64 + 3) {
3042 let (id, rec) = job_with(JobStatus::Pending, i);
3043 ids_by_age.push(id);
3044 jobs.insert(id, rec);
3045 }
3046 evict_jobs_if_over_cap(&jobs);
3047 assert_eq!(jobs.len(), MAX_JOB_RECORDS);
3048 // The three oldest were dropped.
3049 for old in ids_by_age.iter().take(3) {
3050 assert!(
3051 !jobs.contains_key(old),
3052 "oldest Pending record should be evicted"
3053 );
3054 }
3055 }
3056
3057 #[test]
3058 fn exec_error_epoch_ticker_not_running_maps_to_stable_500_message() {
3059 let err = ExecError::EpochTickerNotRunning;
3060 let api: ApiError = err.into();
3061 assert_eq!(api.status, StatusCode::INTERNAL_SERVER_ERROR);
3062 assert_eq!(api.kind, "epoch_ticker_not_running");
3063 assert_eq!(api.message, "engine deadline ticker not running");
3064 // The underlying Display includes a remediation hint
3065 // ("call `engine.spawn_epoch_ticker()` first"); that hint is
3066 // operator-only and must not appear on the wire.
3067 assert!(
3068 !api.message.contains("spawn_epoch_ticker"),
3069 "leaked operator hint: {}",
3070 api.message,
3071 );
3072 }
3073}