Skip to main content

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}