use std::sync::OnceLock;
use std::{
io::Write,
process::{Child, Command, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::Duration,
};
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::{
p1::{self, WasiP1Ctx},
p2::pipe::{MemoryInputPipe, MemoryOutputPipe},
WasiCtxBuilder,
};
use crate::{error::DkpError, DkpResult, Pack};
use super::schema::{EntryPoint, ProcedureDef};
static ENGINE: OnceLock<Engine> = OnceLock::new();
fn engine() -> &'static Engine {
ENGINE.get_or_init(|| {
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
let engine = Engine::new(&config).expect("failed to create wasmtime Engine");
let engine_clone = engine.clone();
std::thread::Builder::new()
.name("dkp-wasm-epoch".into())
.spawn(move || loop {
std::thread::sleep(Duration::from_millis(1));
engine_clone.increment_epoch();
})
.expect("failed to spawn epoch thread");
engine
})
}
pub struct RunOptions {
pub input: serde_json::Value,
pub timeout_ms: Option<u64>,
pub allow_unsigned: bool,
}
pub fn run(pack: &Pack, def: &ProcedureDef, opts: RunOptions) -> DkpResult<serde_json::Value> {
match (&def.wasm_path, &def.entry_point) {
(Some(wasm_path), _) => run_wasm(pack, def, wasm_path, opts),
(None, Some(ep)) => run_subprocess(pack, def, ep, opts),
(None, None) => Err(DkpError::ProcedureNoExecutable { id: def.id.clone() }),
}
}
fn resolve_timeout(opts_ms: Option<u64>, pack: &Pack) -> u64 {
opts_ms
.or_else(|| {
pack.manifest
.procedure_capabilities
.as_ref()
.and_then(|c| c.max_runtime_ms)
})
.unwrap_or(5_000)
}
fn run_wasm(
pack: &Pack,
def: &ProcedureDef,
wasm_path: &std::path::Path,
opts: RunOptions,
) -> DkpResult<serde_json::Value> {
if !pack.has_bundle_sig() {
eprintln!(
"warning: executing procedure '{}' from unsigned bundle '{}'; \
consider running `dkp sign` before distribution",
def.id, pack.manifest.name
);
}
let timeout_ms = resolve_timeout(opts.timeout_ms, pack);
let input_bytes = serde_json::to_vec(&opts.input).map_err(|e| DkpError::AssetParse {
asset: def.id.clone(),
source: e,
})?;
let eng = engine();
let module = Module::from_file(eng, wasm_path).map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: e.to_string(),
})?;
let stdout_pipe = MemoryOutputPipe::new(4 * 1024 * 1024);
let wasi_ctx = WasiCtxBuilder::new()
.stdin(MemoryInputPipe::new(input_bytes))
.stdout(stdout_pipe.clone())
.build_p1();
let mut linker: Linker<WasiP1Ctx> = Linker::new(eng);
p1::add_to_linker_sync(&mut linker, |t| t).map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: e.to_string(),
})?;
let mut store = Store::new(eng, wasi_ctx);
store.set_epoch_deadline(timeout_ms);
let instance =
linker
.instantiate(&mut store, &module)
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: e.to_string(),
})?;
let func = instance
.get_typed_func::<(), ()>(&mut store, "_start")
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: e.to_string(),
})?;
match func.call(&mut store, ()) {
Ok(()) => {}
Err(e) => {
let msg = e.to_string();
if msg.contains("epoch") || msg.contains("interrupt") || msg.contains("timeout") {
return Err(DkpError::ProcedureTimeout {
id: def.id.clone(),
limit_ms: timeout_ms,
});
}
return Err(DkpError::ProcedureTrap {
id: def.id.clone(),
message: msg,
});
}
}
let output_bytes = stdout_pipe.contents();
serde_json::from_slice(&output_bytes).map_err(|e| DkpError::ProcedureInvalidOutput {
id: def.id.clone(),
reason: e.to_string(),
})
}
fn run_subprocess(
pack: &Pack,
def: &ProcedureDef,
ep: &EntryPoint,
opts: RunOptions,
) -> DkpResult<serde_json::Value> {
if !pack.has_bundle_sig() {
if opts.allow_unsigned {
eprintln!(
"warning: executing non-WASM procedure '{}' from unsigned bundle '{}' — \
this procedure has full host access (filesystem, network). \
Sign this bundle with `dkp sign` before distribution.",
def.id, pack.manifest.name
);
} else {
return Err(DkpError::ProcedureUnsignedSubprocess { id: def.id.clone() });
}
}
let timeout_ms = resolve_timeout(opts.timeout_ms, pack);
let proc_dir = pack.procedures_dir();
let argv: Vec<&str> = ep.command.split_whitespace().collect();
let (program, args) = argv.split_first().ok_or_else(|| DkpError::ProcedureTrap {
id: def.id.clone(),
message: "entry_point.command is empty".into(),
})?;
let input_bytes = serde_json::to_vec(&opts.input).map_err(|e| DkpError::AssetParse {
asset: def.id.clone(),
source: e,
})?;
let mut child = Command::new(program)
.args(args)
.current_dir(&proc_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: format!("failed to spawn '{}': {e}", ep.command),
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(&input_bytes)
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: format!("failed to write stdin: {e}"),
})?;
}
let child_shared: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(Some(child)));
let watchdog_shared = Arc::clone(&child_shared);
let killed = Arc::new(AtomicBool::new(false));
let killed_flag = Arc::clone(&killed);
let id_for_watchdog = def.id.clone();
std::thread::Builder::new()
.name(format!("dkp-proc-watchdog-{}", def.id))
.spawn(move || {
std::thread::sleep(Duration::from_millis(timeout_ms));
if let Ok(mut guard) = watchdog_shared.lock() {
if let Some(ref mut c) = *guard {
let _ = c.kill();
killed_flag.store(true, Ordering::SeqCst);
eprintln!(
"dkp: procedure '{}' killed after {}ms timeout",
id_for_watchdog, timeout_ms
);
}
}
})
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: format!("failed to spawn watchdog thread: {e}"),
})?;
let child = child_shared
.lock()
.map_err(|_| DkpError::ProcedureTrap {
id: def.id.clone(),
message: "child lock poisoned".into(),
})?
.take()
.ok_or_else(|| DkpError::ProcedureTrap {
id: def.id.clone(),
message: "child already taken".into(),
})?;
let output = child
.wait_with_output()
.map_err(|e| DkpError::ProcedureTrap {
id: def.id.clone(),
message: format!("failed to wait for child: {e}"),
})?;
if !output.status.success() {
if killed.load(Ordering::SeqCst) {
return Err(DkpError::ProcedureTimeout {
id: def.id.clone(),
limit_ms: timeout_ms,
});
}
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
return Err(DkpError::ProcedureTrap {
id: def.id.clone(),
message: stderr,
});
}
serde_json::from_slice(&output.stdout).map_err(|e| DkpError::ProcedureInvalidOutput {
id: def.id.clone(),
reason: e.to_string(),
})
}