use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use axum::{
body::Body,
extract::{rejection::JsonRejection, Extension, Path, State},
http::{header, HeaderMap, StatusCode},
response::{
sse::{Event, KeepAlive, Sse},
IntoResponse, Response,
},
Json,
};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use dashmap::DashMap;
use futures::stream;
use serde::{Deserialize, Serialize};
use tensor_wasm_core::error::TensorWasmError;
use tensor_wasm_core::metrics::TensorWasmMetrics;
use tensor_wasm_core::types::{InstanceId, TenantId};
use tensor_wasm_exec::engine::TensorWasmEngine;
use tensor_wasm_exec::executor::{ExecError, SpawnConfig, TensorWasmExecutor, WasmArg};
use tensor_wasm_wasi_gpu::streaming::StreamingContext;
use uuid::Uuid;
const INVOKE_DEFAULT_DEADLINE: Duration = Duration::from_secs(30);
pub const WASM_MIN_HEADER_BYTES: usize = 8;
pub const BASE64_OFFLOAD_THRESHOLD: usize = 32 * 1024;
pub const MAX_FUNCTION_NAME_BYTES: usize = 256;
pub const MAX_INVOKE_ARGS: usize = 256;
pub const MAX_OUTSTANDING_ASYNC_JOBS: usize = 256;
pub const MAX_JOB_RECORDS: usize = 4096;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionRecord {
pub id: Uuid,
pub tenant_id: TenantId,
pub name: String,
#[serde(skip, default = "default_wasm_bytes")]
pub wasm_bytes: Arc<[u8]>,
pub created_unix_ms: u64,
}
fn default_wasm_bytes() -> Arc<[u8]> {
Arc::from(Vec::<u8>::new())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Pending,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobRecord {
pub id: Uuid,
pub function_id: Uuid,
pub tenant_id: TenantId,
pub status: JobStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
pub created_unix_ms: u64,
}
#[derive(Clone)]
pub struct AppState {
pub functions: Arc<DashMap<Uuid, FunctionRecord>>,
pub jobs: Arc<DashMap<Uuid, JobRecord>>,
pub metrics: Arc<TensorWasmMetrics>,
pub executor: Arc<TensorWasmExecutor>,
#[cfg(feature = "kernel-registry-api")]
pub kernel_registry: Option<Arc<dyn tensor_wasm_jit::registry::KernelRegistry>>,
pub openai_model_map: crate::openai_translator::ModelMap,
pub app_config: crate::config::AppConfig,
}
impl std::fmt::Debug for AppState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppState")
.field("functions", &self.functions.len())
.field("jobs", &self.jobs.len())
.finish()
}
}
impl AppState {
fn try_build() -> Result<Self, TensorWasmError> {
let metrics = Arc::new(TensorWasmMetrics::new());
let engine = Arc::new(
TensorWasmEngine::new()
.map_err(|e| TensorWasmError::WasmCompile(format!("{e:#}").into()))?,
);
let executor = Arc::new(TensorWasmExecutor::with_metrics(engine, (*metrics).clone()));
Ok(Self {
functions: Arc::new(DashMap::new()),
jobs: Arc::new(DashMap::new()),
metrics,
executor,
#[cfg(feature = "kernel-registry-api")]
kernel_registry: build_kernel_registry_from_env(),
openai_model_map: crate::openai_translator::model_map_from_env(),
app_config: crate::config::AppConfig::default(),
})
}
#[must_use]
pub fn with_app_config(mut self, cfg: crate::config::AppConfig) -> Self {
self.app_config = cfg;
self
}
#[doc(hidden)]
pub fn with_openai_model_map(mut self, map: crate::openai_translator::ModelMap) -> Self {
self.openai_model_map = map;
self
}
pub fn try_new() -> Result<Arc<Self>, TensorWasmError> {
Self::try_build().map(Arc::new)
}
pub fn new() -> Arc<Self> {
Self::try_new().expect("TensorWasmEngine::new must succeed for AppState::new()")
}
pub fn with_metrics(mut self, metrics: Arc<TensorWasmMetrics>) -> Self {
let engine = Arc::new(
TensorWasmEngine::new()
.expect("TensorWasmEngine::new must succeed for AppState::with_metrics"),
);
self.executor = Arc::new(TensorWasmExecutor::with_metrics(engine, (*metrics).clone()));
self.metrics = metrics;
self
}
#[doc(hidden)]
#[cfg(feature = "kernel-registry-api")]
pub fn with_kernel_registry(
mut self,
registry: Arc<dyn tensor_wasm_jit::registry::KernelRegistry>,
) -> Self {
self.kernel_registry = Some(registry);
self
}
}
#[cfg(feature = "kernel-registry-api")]
fn build_kernel_registry_from_env() -> Option<Arc<dyn tensor_wasm_jit::registry::KernelRegistry>> {
const ENV_KERNEL_HMAC_KEY: &str = "TENSOR_WASM_API_KERNEL_HMAC_KEY";
const ENV_KERNEL_REGISTRY_DIR: &str = "TENSOR_WASM_API_KERNEL_REGISTRY_DIR";
let raw = match std::env::var(ENV_KERNEL_HMAC_KEY) {
Ok(s) => s,
Err(_) => return None,
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.len() != 64 {
tracing::warn!(
target: "tensor_wasm_api::routes",
var = ENV_KERNEL_HMAC_KEY,
actual_len = trimmed.len(),
"kernel registry HMAC key must be exactly 64 hex characters; \
leaving registry unconfigured (the /kernels routes will return \
503 kernel_registry_not_configured)",
);
return None;
}
let mut key = [0u8; 32];
for (i, slot) in key.iter_mut().enumerate() {
let bytes = trimmed.as_bytes();
let hi = match hex_nibble_local(bytes[i * 2]) {
Some(v) => v,
None => {
tracing::warn!(
target: "tensor_wasm_api::routes",
var = ENV_KERNEL_HMAC_KEY,
"kernel registry HMAC key contains non-hex characters; \
leaving registry unconfigured",
);
return None;
}
};
let lo = match hex_nibble_local(bytes[i * 2 + 1]) {
Some(v) => v,
None => {
tracing::warn!(
target: "tensor_wasm_api::routes",
var = ENV_KERNEL_HMAC_KEY,
"kernel registry HMAC key contains non-hex characters; \
leaving registry unconfigured",
);
return None;
}
};
*slot = (hi << 4) | lo;
}
if let Ok(dir_raw) = std::env::var(ENV_KERNEL_REGISTRY_DIR) {
let dir_trimmed = dir_raw.trim();
if !dir_trimmed.is_empty() {
let dir = std::path::PathBuf::from(dir_trimmed);
match tensor_wasm_jit::registry::DiskRegistry::open(dir.clone(), key) {
Ok(reg) => {
tracing::info!(
target: "tensor_wasm_api::routes",
dir = %dir.display(),
"kernel registry disk-backed (T35); /kernels routes live",
);
return Some(Arc::new(reg));
}
Err(e) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
dir = %dir.display(),
error = %e,
var = ENV_KERNEL_REGISTRY_DIR,
"disk-backed kernel registry open failed; falling back \
to in-memory registry (manifests will NOT survive restart)",
);
}
}
}
}
tracing::info!(
target: "tensor_wasm_api::routes",
"kernel registry HMAC key configured (64 chars hex); /kernels routes live (in-memory)",
);
Some(Arc::new(tensor_wasm_jit::registry::InMemoryRegistry::new(
key,
)))
}
#[cfg(feature = "kernel-registry-api")]
fn hex_nibble_local(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
}
impl Default for AppState {
fn default() -> Self {
Self::try_build().expect("TensorWasmEngine::new must succeed for AppState::default")
}
}
fn now_unix_ms() -> u64 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_millis() as u64,
Err(e) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
error = %e,
"system clock is before UNIX_EPOCH; returning 0 timestamp",
);
0
}
}
}
fn evict_jobs_if_over_cap(jobs: &DashMap<Uuid, JobRecord>) {
let len = jobs.len();
if len <= MAX_JOB_RECORDS {
return;
}
let overflow = len - MAX_JOB_RECORDS;
let mut entries: Vec<(u64, Uuid, bool)> = jobs
.iter()
.map(|e| {
let rec = e.value();
let terminal = matches!(rec.status, JobStatus::Completed | JobStatus::Failed);
(rec.created_unix_ms, *e.key(), terminal)
})
.collect();
entries.sort_unstable_by(|a, b| {
b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))
});
for (_, id, _) in entries.into_iter().take(overflow) {
jobs.remove(&id);
}
}
#[derive(Debug, Serialize)]
pub struct ApiErrorBody {
pub kind: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct ApiErrorEnvelope {
pub error: ApiErrorBody,
}
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub kind: String,
pub message: String,
}
impl ApiError {
pub fn bad_request(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
kind: kind.into(),
message: message.into(),
}
}
pub fn forbidden(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::FORBIDDEN,
kind: kind.into(),
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
kind: "not_found".to_string(),
message: message.into(),
}
}
pub fn conflict(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
kind: kind.into(),
message: message.into(),
}
}
pub fn service_unavailable(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
kind: kind.into(),
message: message.into(),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
kind: "internal".to_string(),
message: message.into(),
}
}
pub fn not_implemented(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_IMPLEMENTED,
kind: kind.into(),
message: message.into(),
}
}
pub fn body_too_large(message: impl Into<String>) -> Self {
Self {
status: StatusCode::PAYLOAD_TOO_LARGE,
kind: "body_too_large".to_string(),
message: message.into(),
}
}
pub fn to_kind_message(&self) -> (String, String) {
(self.kind.clone(), self.message.clone())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let audit_kind = self.kind.clone();
let body = ApiErrorEnvelope {
error: ApiErrorBody {
kind: self.kind,
message: self.message,
},
};
let mut response = (self.status, Json(body)).into_response();
response
.extensions_mut()
.insert(crate::audit::AuditOutcomeExt {
error_kind: audit_kind,
});
response
}
}
impl From<JsonRejection> for ApiError {
fn from(rej: JsonRejection) -> Self {
if rej.status() == StatusCode::PAYLOAD_TOO_LARGE {
ApiError::body_too_large(rej.body_text())
} else {
ApiError::bad_request("invalid_json", rej.body_text())
}
}
}
pub(crate) fn sanitised_exec_error_message(err: &ExecError) -> &'static str {
match err {
ExecError::NotFound(_) => "function not found",
ExecError::MissingExport(_) => "requested export not found in module",
ExecError::Timeout(_) => "invocation deadline exceeded",
ExecError::Wasmtime(_) => "internal execution error",
ExecError::ModuleMemoryTooLarge { .. } => "module declares memory above per-instance cap",
ExecError::CapacityExhausted { .. } => "engine instance capacity exhausted; retry later",
ExecError::TenantCapacityExhausted { .. } => {
"tenant instance quota exhausted; reduce concurrency or retry later"
}
ExecError::ModuleTooLarge { .. } => "module bytes above per-tenant cap",
ExecError::EpochTickerNotRunning => "engine deadline ticker not running",
}
}
impl From<ExecError> for ApiError {
fn from(err: ExecError) -> Self {
let message = sanitised_exec_error_message(&err).to_string();
match &err {
ExecError::NotFound(id) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
instance_id = %id,
"exec error: instance not found",
);
ApiError {
status: StatusCode::NOT_FOUND,
kind: "instance_not_found".to_string(),
message,
}
}
ExecError::MissingExport(name) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
export = %name,
"exec error: requested export missing from module",
);
ApiError {
status: StatusCode::BAD_REQUEST,
kind: "missing_export".to_string(),
message,
}
}
ExecError::Timeout(ctx) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
instance_id = %ctx.id,
elapsed_ms = ctx.elapsed_ms,
deadline_ms = ctx.deadline_ms,
"exec error: invocation deadline exceeded",
);
ApiError {
status: StatusCode::GATEWAY_TIMEOUT,
kind: "invoke_timeout".to_string(),
message,
}
}
ExecError::Wasmtime(inner) => {
tracing::error!(
target: "tensor_wasm_api::routes",
error = ?inner,
error_chain = %format!("{inner:#}"),
"execution error mapped to 500",
);
ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
kind: "wasmtime".to_string(),
message,
}
}
ExecError::ModuleMemoryTooLarge {
requested_bytes,
limit_bytes,
} => {
tracing::warn!(
target: "tensor_wasm_api::routes",
requested_bytes = *requested_bytes,
limit_bytes = *limit_bytes,
"exec error: module declares memory above per-instance cap",
);
ApiError {
status: StatusCode::PAYLOAD_TOO_LARGE,
kind: "module_memory_too_large".to_string(),
message,
}
}
ExecError::CapacityExhausted { active, limit } => {
tracing::warn!(
target: "tensor_wasm_api::routes",
active = *active,
limit = *limit,
"exec error: engine instance capacity exhausted",
);
ApiError {
status: StatusCode::SERVICE_UNAVAILABLE,
kind: "capacity_exhausted".to_string(),
message,
}
}
ExecError::TenantCapacityExhausted { active, limit, .. } => {
tracing::warn!(
target: "tensor_wasm_api::routes",
active = *active,
limit = *limit,
"exec error: per-tenant instance capacity exhausted",
);
ApiError {
status: StatusCode::TOO_MANY_REQUESTS,
kind: "tenant_capacity_exhausted".to_string(),
message,
}
}
ExecError::ModuleTooLarge { len, max } => {
tracing::warn!(
target: "tensor_wasm_api::routes",
len = *len,
max = *max,
"exec error: module bytes above per-tenant cap",
);
ApiError {
status: StatusCode::PAYLOAD_TOO_LARGE,
kind: "module_too_large".to_string(),
message,
}
}
ExecError::EpochTickerNotRunning => {
tracing::error!(
target: "tensor_wasm_api::routes",
"exec error: engine deadline ticker not running",
);
ApiError {
status: StatusCode::INTERNAL_SERVER_ERROR,
kind: "epoch_ticker_not_running".to_string(),
message,
}
}
}
}
}
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Debug, Deserialize)]
pub struct CreateFunctionRequest {
pub name: String,
pub wasm_b64: String,
}
#[derive(Debug, Serialize)]
pub struct CreateFunctionResponse {
pub id: Uuid,
}
#[derive(Debug, Default, Deserialize)]
pub struct InvokeRequest {
#[serde(default)]
pub export: Option<String>,
#[serde(default)]
pub args: Vec<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct SnapshotSaveRequest {
pub function_id: Uuid,
}
#[derive(Debug, Serialize)]
pub struct SnapshotSaveResponse {
pub function_id: Uuid,
pub snapshot_b64: String,
pub signed: bool,
}
#[derive(Debug, Deserialize)]
pub struct SnapshotRestoreRequest {
pub snapshot_b64: String,
}
#[derive(Debug, Serialize)]
pub struct SnapshotRestoreResponse {
pub tenant_id: u64,
pub instance_id: String,
pub total_uncompressed_bytes: u64,
pub version: u32,
}
pub async fn healthz() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
pub async fn metrics(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let body = state.metrics.encode_text();
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
body,
)
}
fn validate_function_name(name: &str) -> Result<(), ApiError> {
if name.trim().is_empty() {
return Err(ApiError::bad_request(
"invalid_name",
"function name must not be empty",
));
}
if name.len() > MAX_FUNCTION_NAME_BYTES {
return Err(ApiError::bad_request(
"invalid_name",
"function name exceeds 256 bytes",
));
}
if name.chars().any(|c| c.is_control()) {
return Err(ApiError::bad_request(
"invalid_name",
"function name contains control characters",
));
}
Ok(())
}
async fn decode_wasm_b64(wasm_b64: String) -> Result<Vec<u8>, ApiError> {
if wasm_b64.len() < BASE64_OFFLOAD_THRESHOLD {
return BASE64
.decode(wasm_b64.as_bytes())
.map_err(base64_decode_error);
}
tokio::task::spawn_blocking(move || BASE64.decode(wasm_b64.as_bytes()))
.await
.map_err(|e| ApiError::internal(format!("base64 decoder panicked: {e}")))?
.map_err(base64_decode_error)
}
fn base64_decode_error(e: base64::DecodeError) -> ApiError {
tracing::warn!(
target: "tensor_wasm_api::routes",
error = %e,
"rejected create_function: base64 decode failed",
);
ApiError::bad_request("invalid_base64", "invalid base64 payload")
}
pub(crate) fn require_authorize(
auth: &Option<Extension<crate::rate_limit::AuthContext>>,
tenant: TenantId,
) -> Result<(), ApiError> {
match auth.as_ref() {
Some(Extension(ctx)) => ctx.authorize_tenant(tenant),
None => {
tracing::error!(
target: "tensor_wasm_api::routes",
tenant = %tenant,
"auth context missing: router misconfigured (bearer_auth layer not applied)",
);
Err(ApiError::internal(
"auth context missing: router misconfigured",
))
}
}
}
#[tracing::instrument(
name = "http.create_function",
skip(state, auth, payload),
fields(
function_id = tracing::field::Empty,
tenant = tracing::field::Empty,
),
)]
pub async fn create_function(
State(state): State<Arc<AppState>>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
payload: Result<Json<CreateFunctionRequest>, JsonRejection>,
) -> ApiResult<Json<CreateFunctionResponse>> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let Json(req) = payload?;
validate_function_name(&req.name)?;
let bytes = decode_wasm_b64(req.wasm_b64).await?;
if bytes.len() < WASM_MIN_HEADER_BYTES {
return Err(ApiError::bad_request(
"invalid_wasm",
format!(
"module too short: {} bytes (minimum {WASM_MIN_HEADER_BYTES})",
bytes.len()
),
));
}
let bytes = tokio::task::spawn_blocking(move || {
let validate_result = wasmparser::validate(&bytes);
(bytes, validate_result)
})
.await
.map_err(|e| ApiError::internal(format!("wasm validator panicked: {e}")))?;
let (bytes, validate_result) = bytes;
if let Err(e) = validate_result {
tracing::warn!(
target: "tensor_wasm_api::routes",
error = %e,
"rejected create_function: wasm validation failed",
);
return Err(ApiError::bad_request("invalid_wasm", "invalid wasm module"));
}
let id = Uuid::new_v4();
tracing::Span::current().record("function_id", tracing::field::display(id));
state.functions.insert(
id,
FunctionRecord {
id,
tenant_id: tenant,
name: req.name,
wasm_bytes: Arc::from(bytes),
created_unix_ms: now_unix_ms(),
},
);
Ok(Json(CreateFunctionResponse { id }))
}
#[tracing::instrument(
name = "http.delete_function",
skip(state, auth),
fields(
function_id = %id,
tenant = tracing::field::Empty,
),
)]
pub async fn delete_function(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
) -> ApiResult<StatusCode> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
match state.functions.get(&id) {
Some(entry) => {
if entry.value().tenant_id != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"function belongs to a different tenant",
));
}
}
None => return Err(ApiError::not_found(format!("function {id} not found"))),
}
if state.functions.remove(&id).is_some() {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found(format!("function {id} not found")))
}
}
fn parse_invoke_args(raw: &[serde_json::Value]) -> Result<Vec<WasmArg>, ApiError> {
if raw.len() > MAX_INVOKE_ARGS {
return Err(ApiError::bad_request(
"too_many_args",
format!(
"args list has {} elements (maximum {MAX_INVOKE_ARGS})",
raw.len()
),
));
}
raw.iter()
.enumerate()
.map(|(i, v)| {
WasmArg::from_json(v).map_err(|msg| {
ApiError::bad_request("invalid_args", format!("args[{i}]: {msg} (value: {v})"))
})
})
.collect()
}
#[tracing::instrument(
name = "invoke.run",
skip(executor, wasm_bytes, args),
fields(
tenant = %tenant,
function_id = %function_id,
wasm_bytes_len = wasm_bytes.len(),
export = tracing::field::Empty,
args_len = args.len(),
),
)]
async fn run_invoke(
executor: &TensorWasmExecutor,
wasm_bytes: &[u8],
tenant: TenantId,
function_id: Uuid,
export_override: Option<&str>,
args: &[WasmArg],
) -> ApiResult<serde_json::Value> {
let build_cfg = || {
let cfg = SpawnConfig::for_tenant(tenant).with_deadline(INVOKE_DEFAULT_DEADLINE);
if args.is_empty() {
cfg
} else {
cfg.with_args(args.to_vec())
}
};
let instance_id = executor.spawn_instance(build_cfg(), wasm_bytes).await?;
let result_value: serde_json::Value = if let Some(name) = export_override {
tracing::Span::current().record("export", tracing::field::display(name));
executor
.call_export_with_args_then_terminate(instance_id, name, args)
.await?
} else {
tracing::Span::current().record("export", tracing::field::display("_start|main"));
match executor
.call_export_with_args_then_terminate(instance_id, "_start", args)
.await
{
Ok(v) => v,
Err(ExecError::MissingExport(_)) => {
let retry_id = executor.spawn_instance(build_cfg(), wasm_bytes).await?;
executor
.call_export_with_args_then_terminate(retry_id, "main", args)
.await?
}
Err(other) => return Err(other.into()),
}
};
let payload_result = match &result_value {
serde_json::Value::Array(items) if items.is_empty() => {
serde_json::Value::String("ok".to_string())
}
_ => result_value,
};
Ok(serde_json::json!({
"function_id": function_id.to_string(),
"result": payload_result,
}))
}
async fn read_invoke_request(
payload: Result<Json<InvokeRequest>, JsonRejection>,
) -> ApiResult<InvokeRequest> {
match payload {
Ok(Json(req)) => Ok(req),
Err(rej) => {
if rej.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE
|| rej.body_text().trim().is_empty()
{
Ok(InvokeRequest::default())
} else {
Err(rej.into())
}
}
}
}
#[tracing::instrument(
name = "http.invoke_function",
skip(state, auth, payload),
fields(
function_id = %id,
tenant = tracing::field::Empty,
),
)]
pub async fn invoke_function(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
payload: Result<Json<InvokeRequest>, JsonRejection>,
) -> ApiResult<Json<serde_json::Value>> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let req = read_invoke_request(payload).await?;
let args = parse_invoke_args(&req.args)?;
let (wasm_bytes, owner) = match state.functions.get(&id) {
Some(entry) => (
Arc::clone(&entry.value().wasm_bytes),
entry.value().tenant_id,
),
None => return Err(ApiError::not_found(format!("function {id} not found"))),
};
if owner != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"function belongs to a different tenant",
));
}
let value = run_invoke(
&state.executor,
&wasm_bytes,
tenant,
id,
req.export.as_deref(),
&args,
)
.await?;
Ok(Json(value))
}
struct JobsActiveGuard {
metrics: Arc<TensorWasmMetrics>,
decremented: bool,
}
impl JobsActiveGuard {
fn new(metrics: Arc<TensorWasmMetrics>) -> Self {
metrics.jobs_active().inc();
Self {
metrics,
decremented: false,
}
}
fn release(mut self) {
self.metrics.jobs_active().dec();
self.decremented = true;
}
}
impl Drop for JobsActiveGuard {
fn drop(&mut self) {
if !self.decremented {
self.metrics.jobs_active().dec();
tracing::warn!(
target: "tensor_wasm_api::routes",
"jobs_active gauge decremented via Drop (likely task panic or cancellation)",
);
}
}
}
#[tracing::instrument(
name = "http.invoke_function_async",
skip(state, auth, payload),
fields(
function_id = %id,
tenant = tracing::field::Empty,
job_id = tracing::field::Empty,
),
)]
pub async fn invoke_function_async(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
payload: Result<Json<InvokeRequest>, JsonRejection>,
) -> ApiResult<(StatusCode, Json<serde_json::Value>)> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let req = read_invoke_request(payload).await?;
let args = parse_invoke_args(&req.args)?;
let export_override = req.export;
let (wasm_bytes, owner) = match state.functions.get(&id) {
Some(entry) => (
Arc::clone(&entry.value().wasm_bytes),
entry.value().tenant_id,
),
None => return Err(ApiError::not_found(format!("function {id} not found"))),
};
if owner != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"function belongs to a different tenant",
));
}
if state.metrics.jobs_active().get() as usize >= MAX_OUTSTANDING_ASYNC_JOBS {
tracing::warn!(
target: "tensor_wasm_api::routes",
active = state.metrics.jobs_active().get(),
limit = MAX_OUTSTANDING_ASYNC_JOBS,
"rejected invoke-async: outstanding async-job ceiling reached",
);
return Err(ApiError::service_unavailable(
"capacity_exhausted",
"outstanding async-job capacity exhausted; retry later",
));
}
let jobs_active_guard = JobsActiveGuard::new(Arc::clone(&state.metrics));
let job_id = Uuid::new_v4();
tracing::Span::current().record("job_id", tracing::field::display(job_id));
state.jobs.insert(
job_id,
JobRecord {
id: job_id,
function_id: id,
tenant_id: tenant,
status: JobStatus::Pending,
result: None,
created_unix_ms: now_unix_ms(),
},
);
evict_jobs_if_over_cap(&state.jobs);
let executor = Arc::clone(&state.executor);
let jobs = Arc::clone(&state.jobs);
let job_span = tracing::info_span!(
"async_invoke.job",
job_id = %job_id,
function_id = %id,
tenant = %tenant,
);
tokio::spawn(tracing::Instrument::instrument(
async move {
let guard = jobs_active_guard;
test_hooks::maybe_panic_for_test();
let outcome = run_invoke(
&executor,
&wasm_bytes,
tenant,
id,
export_override.as_deref(),
&args,
)
.await;
if let Some(mut entry) = jobs.get_mut(&job_id) {
match outcome {
Ok(value) => {
entry.status = JobStatus::Completed;
entry.result = Some(value);
}
Err(api_err) => {
let (kind, message) = api_err.to_kind_message();
entry.status = JobStatus::Failed;
entry.result = Some(serde_json::json!({
"kind": kind,
"message": message,
}));
}
}
}
guard.release();
},
job_span,
));
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "job_id": job_id.to_string() })),
))
}
pub const SSE_MIME: &str = "text/event-stream";
pub const CHUNKED_MIME: &str = "application/octet-stream";
fn accept_wants_sse(headers: &HeaderMap) -> bool {
headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|s| {
s.split(',')
.any(|part| part.trim().to_ascii_lowercase().starts_with(SSE_MIME))
})
}
pub const STREAMING_CHANNEL_BUFFER: usize = 32;
#[tracing::instrument(
name = "http.invoke_function_stream",
skip(state, auth, headers, payload),
fields(
function_id = %id,
tenant = tracing::field::Empty,
sse = tracing::field::Empty,
),
)]
pub async fn invoke_function_stream(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
headers: HeaderMap,
payload: Result<Json<InvokeRequest>, JsonRejection>,
) -> Result<Response, ApiError> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let (wasm_bytes, owner) = match state.functions.get(&id) {
Some(entry) => (
Arc::clone(&entry.value().wasm_bytes),
entry.value().tenant_id,
),
None => return Err(ApiError::not_found(format!("function {id} not found"))),
};
if owner != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"function belongs to a different tenant",
));
}
let req = read_invoke_request(payload).await?;
let args = parse_invoke_args(&req.args)?;
let export_override = req.export;
if state.metrics.jobs_active().get() as usize >= MAX_OUTSTANDING_ASYNC_JOBS {
tracing::warn!(
target: "tensor_wasm_api::routes",
active = state.metrics.jobs_active().get(),
limit = MAX_OUTSTANDING_ASYNC_JOBS,
"rejected invoke-stream: outstanding async-job ceiling reached",
);
return Err(ApiError::service_unavailable(
"capacity_exhausted",
"outstanding async-job capacity exhausted; retry later",
));
}
let jobs_active_guard = JobsActiveGuard::new(Arc::clone(&state.metrics));
let wants_sse = accept_wants_sse(&headers);
tracing::Span::current().record("sse", tracing::field::display(wants_sse));
let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(STREAMING_CHANNEL_BUFFER);
let streaming = StreamingContext::with_channel(chunk_tx);
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<Result<(), StreamTerminalError>>();
let executor = state.executor.clone();
tokio::spawn(async move {
let guard = jobs_active_guard;
let cfg = SpawnConfig::for_tenant(tenant)
.with_deadline(INVOKE_DEFAULT_DEADLINE)
.with_streaming(streaming);
let outcome = match executor.spawn_instance(cfg, &wasm_bytes).await {
Ok(instance_id) => {
let call_result = match export_override.as_deref() {
Some(name) => {
executor
.call_export_with_args_then_terminate(instance_id, name, &args)
.await
}
None => {
match executor
.call_export_with_args_then_terminate(instance_id, "_start", &args)
.await
{
Ok(v) => Ok(v),
Err(ExecError::MissingExport(_)) => {
let cfg2 = SpawnConfig::for_tenant(tenant)
.with_deadline(INVOKE_DEFAULT_DEADLINE);
match executor.spawn_instance(cfg2, &wasm_bytes).await {
Ok(retry_id) => {
executor
.call_export_with_args_then_terminate(
retry_id, "main", &args,
)
.await
}
Err(e) => Err(e),
}
}
Err(other) => Err(other),
}
}
};
call_result.map(|_| ())
}
Err(e) => Err(e),
};
let terminal = match outcome {
Ok(()) => Ok(()),
Err(ExecError::Timeout(ctx)) => {
Err(StreamTerminalError {
kind: "deadline_elapsed",
message: format!(
"instance exceeded deadline ({} ms / {} ms)",
ctx.elapsed_ms, ctx.deadline_ms,
),
})
}
Err(e) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
error = ?e,
"invoke-stream terminal error mapped to sanitised SSE frame",
);
Err(StreamTerminalError {
kind: "wasm_error",
message: sanitised_exec_error_message(&e).to_string(),
})
}
};
let _ = done_tx.send(terminal);
guard.release();
});
let metrics_for_writer = state.metrics.clone();
let initial = StreamWriterState::Streaming(chunk_rx, done_rx);
let body_stream = stream::unfold(initial, move |st| {
let metrics = metrics_for_writer.clone();
async move {
match st {
StreamWriterState::Streaming(mut rx, mut done_rx) => {
tokio::select! {
biased;
maybe_chunk = rx.recv() => {
match maybe_chunk {
Some(c) => {
metrics.streaming_chunks_emitted_total().inc();
Some((
StreamFrame::Chunk(c),
StreamWriterState::Streaming(rx, done_rx),
))
}
None => {
let terminal = done_rx.await.unwrap_or_else(|_| {
Err(StreamTerminalError {
kind: "wasm_error",
message: "executor task dropped without signalling".to_string(),
})
});
Some((StreamFrame::Done(terminal), StreamWriterState::Done))
}
}
}
done = &mut done_rx => {
let terminal = done.unwrap_or_else(|_| {
Err(StreamTerminalError {
kind: "wasm_error",
message: "executor task dropped without signalling".to_string(),
})
});
match rx.try_recv() {
Ok(c) => {
metrics.streaming_chunks_emitted_total().inc();
Some((
StreamFrame::Chunk(c),
StreamWriterState::DrainOnly(rx, Some(terminal)),
))
}
Err(_) => Some((
StreamFrame::Done(terminal),
StreamWriterState::Done,
)),
}
}
}
}
StreamWriterState::DrainOnly(mut rx, terminal) => match rx.try_recv() {
Ok(c) => {
metrics.streaming_chunks_emitted_total().inc();
Some((
StreamFrame::Chunk(c),
StreamWriterState::DrainOnly(rx, terminal),
))
}
Err(_) => Some((
StreamFrame::Done(terminal.unwrap_or(Ok(()))),
StreamWriterState::Done,
)),
},
StreamWriterState::Done => None,
}
}
});
use futures::StreamExt;
if wants_sse {
let sse_stream = body_stream.map(|item| {
let ev = match item {
StreamFrame::Chunk(bytes) => {
let s = String::from_utf8_lossy(&bytes).into_owned();
Event::default().event("chunk").data(s)
}
StreamFrame::Done(Ok(())) => Event::default()
.event("done")
.data(serde_json::json!({"status":"ok"}).to_string()),
StreamFrame::Done(Err(err)) => Event::default().event("error").data(
serde_json::json!({
"reason": err.kind,
"message": err.message,
})
.to_string(),
),
};
Ok::<Event, std::convert::Infallible>(ev)
});
Ok(Sse::new(sse_stream)
.keep_alive(KeepAlive::default())
.into_response())
} else {
let byte_stream = body_stream.map(|item| {
let bytes: axum::body::Bytes = match item {
StreamFrame::Chunk(c) => axum::body::Bytes::from(c),
StreamFrame::Done(Ok(())) => axum::body::Bytes::from(format!(
"event: done\ndata: {}\n\n",
serde_json::json!({"status":"ok"})
)),
StreamFrame::Done(Err(err)) => axum::body::Bytes::from(format!(
"event: error\ndata: {}\n\n",
serde_json::json!({"reason": err.kind, "message": err.message})
)),
};
Ok::<axum::body::Bytes, std::io::Error>(bytes)
});
let mut resp = Response::new(Body::from_stream(byte_stream));
resp.headers_mut().insert(
header::CONTENT_TYPE,
CHUNKED_MIME.parse().expect("static mime parses"),
);
Ok(resp)
}
}
#[derive(Debug, Clone)]
struct StreamTerminalError {
kind: &'static str,
message: String,
}
enum StreamFrame {
Chunk(Vec<u8>),
Done(Result<(), StreamTerminalError>),
}
enum StreamWriterState {
Streaming(
tokio::sync::mpsc::Receiver<Vec<u8>>,
tokio::sync::oneshot::Receiver<Result<(), StreamTerminalError>>,
),
DrainOnly(
tokio::sync::mpsc::Receiver<Vec<u8>>,
Option<Result<(), StreamTerminalError>>,
),
Done,
}
#[tracing::instrument(
name = "http.get_job",
skip(state, auth),
fields(
job_id = %id,
tenant = tracing::field::Empty,
),
)]
pub async fn get_job(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
) -> ApiResult<Json<JobRecord>> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let owner = match state.jobs.get(&id) {
Some(rec) => rec.value().tenant_id,
None => return Err(ApiError::not_found(format!("job {id} not found"))),
};
if owner != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"job belongs to a different tenant",
));
}
match state.jobs.get(&id) {
Some(rec) => Ok(Json(rec.clone())),
None => Err(ApiError::not_found(format!("job {id} not found"))),
}
}
#[tracing::instrument(
name = "http.snapshot_save",
skip(state, auth, payload),
fields(
function_id = tracing::field::Empty,
tenant = tracing::field::Empty,
),
)]
pub async fn snapshot_save(
State(state): State<Arc<AppState>>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
payload: Result<Json<SnapshotSaveRequest>, JsonRejection>,
) -> ApiResult<Json<SnapshotSaveResponse>> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let key = match state.app_config.snapshot_hmac_key {
Some(k) => k,
None => {
return Err(ApiError::service_unavailable(
"snapshot_signing_not_configured",
"snapshot signing key is not configured; set \
TENSOR_WASM_API_SNAPSHOT_HMAC_KEY to enable /snapshot/save",
));
}
};
let Json(req) = payload?;
tracing::Span::current().record("function_id", tracing::field::display(req.function_id));
let (wasm_bytes, owner) = match state.functions.get(&req.function_id) {
Some(entry) => (
Arc::clone(&entry.value().wasm_bytes),
entry.value().tenant_id,
),
None => {
return Err(ApiError::not_found(format!(
"function {} not found",
req.function_id
)))
}
};
if owner != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"function belongs to a different tenant",
));
}
let instance_id = InstanceId(req.function_id.as_u128() & u128::from(u64::MAX));
let snapshot_bytes = tokio::task::spawn_blocking(move || {
use tensor_wasm_snapshot::writer::{InstanceState, SnapshotWriter};
SnapshotWriter::new()
.with_hmac_sha256_key(key)
.with_legacy_envelope()
.capture(InstanceState {
tenant_id: tenant,
instance_id,
wasm_memory: &wasm_bytes,
gpu_memory: &[],
registers: &[],
})
})
.await
.map_err(|e| ApiError::internal(format!("snapshot capture task panicked: {e}")))?
.map_err(|e| {
tracing::error!(
target: "tensor_wasm_api::routes",
error = %e,
"snapshot capture failed",
);
ApiError::internal("snapshot capture failed")
})?;
let snapshot_b64 = BASE64.encode(&snapshot_bytes);
Ok(Json(SnapshotSaveResponse {
function_id: req.function_id,
snapshot_b64,
signed: true,
}))
}
#[tracing::instrument(
name = "http.snapshot_restore",
skip(state, auth, payload),
fields(tenant = tracing::field::Empty),
)]
pub async fn snapshot_restore(
State(state): State<Arc<AppState>>,
tenant: Option<Extension<TenantId>>,
auth: Option<Extension<crate::rate_limit::AuthContext>>,
payload: Result<Json<SnapshotRestoreRequest>, JsonRejection>,
) -> ApiResult<Json<SnapshotRestoreResponse>> {
let tenant = tenant.map(|Extension(t)| t).unwrap_or(TenantId(0));
tracing::Span::current().record("tenant", tracing::field::display(tenant));
require_authorize(&auth, tenant)?;
let key = match state.app_config.snapshot_hmac_key {
Some(k) => k,
None => {
return Err(ApiError::service_unavailable(
"snapshot_signing_not_configured",
"snapshot signing key is not configured; set \
TENSOR_WASM_API_SNAPSHOT_HMAC_KEY to enable /snapshot/restore",
));
}
};
let Json(req) = payload?;
let snapshot_bytes = BASE64
.decode(req.snapshot_b64.as_bytes())
.map_err(base64_decode_error)?;
if state.app_config.snapshot_require_signature {
tracing::debug!(
target: "tensor_wasm_api::routes",
"snapshot restore: require-signature explicitly enabled by operator",
);
}
let restored = tokio::task::spawn_blocking(move || {
use tensor_wasm_snapshot::reader::SnapshotReader;
SnapshotReader::new()
.with_hmac_sha256_key(key)
.require_signature()
.restore(&snapshot_bytes)
})
.await
.map_err(|e| ApiError::internal(format!("snapshot restore task panicked: {e}")))?;
let snapshot = match restored {
Ok(s) => s,
Err(e) => {
tracing::warn!(
target: "tensor_wasm_api::routes",
error = %e,
"snapshot restore rejected (signature / structural verification failed)",
);
return Err(ApiError::forbidden(
"snapshot_signature_invalid",
"snapshot signature verification failed or blob is malformed",
));
}
};
if snapshot.metadata.tenant_id != tenant {
return Err(ApiError::forbidden(
"tenant_scope_denied",
"snapshot was captured for a different tenant",
));
}
Ok(Json(SnapshotRestoreResponse {
tenant_id: snapshot.metadata.tenant_id.0,
instance_id: snapshot.metadata.instance_id.to_string(),
total_uncompressed_bytes: snapshot.metadata.total_uncompressed_bytes,
version: snapshot.version,
}))
}
#[doc(hidden)]
pub mod test_hooks {
use std::sync::atomic::{AtomicBool, Ordering};
static PANIC_NEXT_INVOKE: AtomicBool = AtomicBool::new(false);
pub fn arm_panic() -> ArmedPanic {
PANIC_NEXT_INVOKE.store(true, Ordering::SeqCst);
ArmedPanic
}
pub struct ArmedPanic;
impl Drop for ArmedPanic {
fn drop(&mut self) {
PANIC_NEXT_INVOKE.store(false, Ordering::SeqCst);
}
}
#[inline]
pub(crate) fn maybe_panic_for_test() {
if PANIC_NEXT_INVOKE.load(Ordering::Relaxed)
&& PANIC_NEXT_INVOKE
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
panic!(
"test_hooks::maybe_panic_for_test: deliberate panic for JobsActiveGuard Drop test"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_error_constructors_carry_status() {
let bad = ApiError::bad_request("invalid_name", "x");
assert_eq!(bad.status, StatusCode::BAD_REQUEST);
assert_eq!(bad.kind, "invalid_name");
let nf = ApiError::not_found("missing");
assert_eq!(nf.status, StatusCode::NOT_FOUND);
assert_eq!(nf.kind, "not_found");
let oops = ApiError::internal("boom");
assert_eq!(oops.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(oops.kind, "internal");
}
#[test]
fn app_state_default_is_empty() {
let s = AppState::default();
assert_eq!(s.functions.len(), 0);
assert_eq!(s.jobs.len(), 0);
let m = s.metrics.encode_text();
assert!(m.contains("tensor_wasm_active_instances"), "got: {m}");
}
#[test]
fn app_state_with_metrics_swaps_handle() {
let custom = Arc::new(TensorWasmMetrics::new());
custom.kernel_dispatches_total().inc();
let s = AppState::default().with_metrics(custom.clone());
let text = s.metrics.encode_text();
assert!(
text.contains("tensor_wasm_kernel_dispatches_total 1"),
"got: {text}"
);
}
#[test]
fn app_state_try_new_constructs() {
let s = AppState::try_new().expect("default engine builds on this host");
assert_eq!(s.functions.len(), 0);
assert_eq!(s.jobs.len(), 0);
}
#[test]
fn function_record_skips_wasm_bytes_on_wire() {
let rec = FunctionRecord {
id: Uuid::nil(),
tenant_id: TenantId(0),
name: "n".to_string(),
wasm_bytes: Arc::from(vec![1u8, 2, 3]),
created_unix_ms: 0,
};
let v = serde_json::to_value(&rec).unwrap();
assert!(v.get("wasm_bytes").is_none(), "wasm_bytes leaked: {v}");
}
#[tokio::test]
async fn exec_error_compile_maps_to_wasmtime_500() {
let engine = std::sync::Arc::new(TensorWasmEngine::new().expect("engine"));
let exec = TensorWasmExecutor::new(engine);
let err = exec
.spawn_instance(SpawnConfig::for_tenant(TenantId(0)), b"not wasm")
.await
.expect_err("compile fails");
assert!(matches!(err, ExecError::Wasmtime(_)));
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api.kind, "wasmtime");
}
#[test]
fn jobs_active_guard_release_decrements_exactly_once() {
let metrics = Arc::new(TensorWasmMetrics::new());
assert_eq!(metrics.jobs_active().get(), 0);
let g = JobsActiveGuard::new(Arc::clone(&metrics));
assert_eq!(metrics.jobs_active().get(), 1);
g.release();
assert_eq!(metrics.jobs_active().get(), 0);
}
#[test]
fn jobs_active_guard_drop_decrements_on_panic_path() {
let metrics = Arc::new(TensorWasmMetrics::new());
{
let _g = JobsActiveGuard::new(Arc::clone(&metrics));
assert_eq!(metrics.jobs_active().get(), 1);
}
assert_eq!(
metrics.jobs_active().get(),
0,
"Drop must decrement the gauge when release was not called"
);
}
#[test]
fn exec_error_timeout_maps_to_invoke_timeout() {
use tensor_wasm_core::types::InstanceId;
use tensor_wasm_exec::executor::TimeoutContext;
let err = ExecError::Timeout(TimeoutContext {
id: InstanceId(7),
elapsed_ms: 1000,
deadline_ms: 500,
});
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::GATEWAY_TIMEOUT);
assert_eq!(api.kind, "invoke_timeout");
assert_eq!(api.message, "invocation deadline exceeded");
assert!(
!api.message.contains("I#7"),
"leaked instance id: {}",
api.message,
);
assert!(
!api.message.contains("1000") && !api.message.contains("500"),
"leaked timing figures: {}",
api.message,
);
}
#[test]
fn exec_error_not_found_maps_to_stable_404_message() {
use tensor_wasm_core::types::InstanceId;
let err = ExecError::NotFound(InstanceId(42));
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::NOT_FOUND);
assert_eq!(api.kind, "instance_not_found");
assert_eq!(api.message, "function not found");
assert!(
!api.message.contains("42") && !api.message.contains("I#"),
"leaked instance id: {}",
api.message,
);
}
#[test]
fn exec_error_missing_export_maps_to_stable_400_message() {
let err = ExecError::MissingExport("super_secret_internal_symbol".to_string());
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::BAD_REQUEST);
assert_eq!(api.kind, "missing_export");
assert_eq!(api.message, "requested export not found in module");
assert!(
!api.message.contains("super_secret_internal_symbol"),
"leaked export name: {}",
api.message,
);
}
#[test]
fn exec_error_module_memory_too_large_maps_to_stable_413_message() {
let err = ExecError::ModuleMemoryTooLarge {
requested_bytes: 4_294_967_296,
limit_bytes: 67_108_864,
};
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(api.kind, "module_memory_too_large");
assert_eq!(api.message, "module declares memory above per-instance cap",);
assert!(
!api.message.contains("4294967296") && !api.message.contains("67108864"),
"leaked byte figures: {}",
api.message,
);
}
#[test]
fn exec_error_capacity_exhausted_maps_to_stable_503_message() {
let err = ExecError::CapacityExhausted {
active: 257,
limit: 256,
};
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(api.kind, "capacity_exhausted");
assert_eq!(
api.message,
"engine instance capacity exhausted; retry later",
);
assert!(
!api.message.contains("257") && !api.message.contains("256"),
"leaked capacity figures: {}",
api.message,
);
}
#[test]
fn exec_error_module_too_large_maps_to_stable_413_message() {
let err = ExecError::ModuleTooLarge {
len: 16_777_217,
max: 16_777_216,
};
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(api.kind, "module_too_large");
assert_eq!(api.message, "module bytes above per-tenant cap");
assert!(
!api.message.contains("16777217") && !api.message.contains("16777216"),
"leaked size figures: {}",
api.message,
);
}
fn job_with(status: JobStatus, created_unix_ms: u64) -> (Uuid, JobRecord) {
let id = Uuid::new_v4();
(
id,
JobRecord {
id,
function_id: Uuid::nil(),
tenant_id: TenantId(0),
status,
result: None,
created_unix_ms,
},
)
}
#[test]
fn evict_jobs_noop_when_under_cap() {
let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
for i in 0..10u64 {
let (id, rec) = job_with(JobStatus::Completed, i);
jobs.insert(id, rec);
}
evict_jobs_if_over_cap(&jobs);
assert_eq!(jobs.len(), 10, "no eviction expected under the cap");
}
#[test]
fn evict_jobs_prefers_terminal_oldest_first() {
let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
let (pending_id, pending) = job_with(JobStatus::Pending, 0);
jobs.insert(pending_id, pending);
let mut completed_ids_by_age: Vec<Uuid> = Vec::new();
for i in 0..(MAX_JOB_RECORDS as u64 + 1) {
let (id, rec) = job_with(JobStatus::Completed, i + 1);
completed_ids_by_age.push(id);
jobs.insert(id, rec);
}
assert_eq!(jobs.len(), MAX_JOB_RECORDS + 2);
evict_jobs_if_over_cap(&jobs);
assert_eq!(jobs.len(), MAX_JOB_RECORDS);
assert!(
jobs.contains_key(&pending_id),
"in-flight Pending job must not be evicted while terminal records exist",
);
assert!(
!jobs.contains_key(&completed_ids_by_age[0]),
"oldest terminal record should be evicted",
);
assert!(
!jobs.contains_key(&completed_ids_by_age[1]),
"second-oldest terminal record should be evicted",
);
assert!(
jobs.contains_key(&completed_ids_by_age[completed_ids_by_age.len() - 1]),
"newest terminal record should survive",
);
}
#[test]
fn evict_jobs_falls_back_to_oldest_when_all_pending() {
let jobs: DashMap<Uuid, JobRecord> = DashMap::new();
let mut ids_by_age: Vec<Uuid> = Vec::new();
for i in 0..(MAX_JOB_RECORDS as u64 + 3) {
let (id, rec) = job_with(JobStatus::Pending, i);
ids_by_age.push(id);
jobs.insert(id, rec);
}
evict_jobs_if_over_cap(&jobs);
assert_eq!(jobs.len(), MAX_JOB_RECORDS);
for old in ids_by_age.iter().take(3) {
assert!(
!jobs.contains_key(old),
"oldest Pending record should be evicted"
);
}
}
#[test]
fn exec_error_epoch_ticker_not_running_maps_to_stable_500_message() {
let err = ExecError::EpochTickerNotRunning;
let api: ApiError = err.into();
assert_eq!(api.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(api.kind, "epoch_ticker_not_running");
assert_eq!(api.message, "engine deadline ticker not running");
assert!(
!api.message.contains("spawn_epoch_ticker"),
"leaked operator hint: {}",
api.message,
);
}
}