use std::collections::BTreeMap;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use afterburner_afb::Afb;
use afterburner_core::{AfterburnerError, EnvAccess, FsAccess, Manifold, Result};
use afterburner_wasi::embedder_vm::{BoundedCommandOutput, CommandOutcome, WasiCommandOpts};
const RUBY_WASM_RUNTIME_TARGET: &str = "wasm32-wasip1";
const PRECOMPILED_WASM_MEMBER: &str = "precompiled/wasm32-wasip1/main.wasm";
static NEXT_SCRATCH_ID: AtomicU64 = AtomicU64::new(0);
fn unique_scratch_dir(prefix: &str) -> std::path::PathBuf {
let n = NEXT_SCRATCH_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{}-{n}", std::process::id()))
}
#[derive(Debug, Clone, Default)]
pub struct AfbRunRequest {
pub stdin: Vec<u8>,
pub args: Vec<String>,
pub manifold: Manifold,
pub fuel: Option<u64>,
pub memory_bytes: Option<u64>,
pub timeout: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AfbRunOutcome {
Exited(i32),
OutOfFuel,
OutOfMemory,
Timeout,
Trapped(String),
}
#[derive(Debug, Clone)]
pub struct AfbRunOutput {
pub outcome: AfbRunOutcome,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub fuel_used: u64,
}
pub fn run_afb_bytes(afb: &[u8], request: AfbRunRequest) -> Result<AfbRunOutput> {
let parsed =
Afb::from_bytes(afb).map_err(|e| AfterburnerError::Engine(format!("parsing .afb: {e}")))?;
dispatch(&parsed, request)
}
fn dispatch(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
let runtime_target = afb.manifest.runtime.target.as_deref().unwrap_or("");
if runtime_target == afterburner_wasi::pyodide_runner::RUNTIME_TARGET {
return run_python_wasm(afb, request);
}
match afb.manifest.package.language.to_ascii_lowercase().as_str() {
"python" | "py" => run_python_source(afb, request),
"ruby" | "rb" if runtime_target == RUBY_WASM_RUNTIME_TARGET => run_ruby_wasm(afb, request),
"ruby" | "rb" => run_ruby_source(afb, request),
_ => run_wasm(afb, request),
}
}
fn manifold_to_wasi_opts(manifold: &Manifold, mut opts: WasiCommandOpts) -> WasiCommandOpts {
match &manifold.fs {
FsAccess::None => {}
FsAccess::ReadOnly(paths) => {
for p in paths {
let guest = p.to_string_lossy().into_owned();
opts = opts.preopen_ro(p, guest);
}
}
FsAccess::ReadWrite(paths) => {
for p in paths {
let guest = p.to_string_lossy().into_owned();
opts = opts.preopen_rw(p, guest);
}
}
}
match &manifold.env {
EnvAccess::None => {}
EnvAccess::AllowList(keys) => {
for key in keys {
if let Ok(val) = std::env::var(key) {
opts = opts.env_var(key.clone(), val);
}
}
}
EnvAccess::Full => {
for (key, val) in std::env::vars() {
opts = opts.env_var(key, val);
}
}
}
opts
}
fn finish(raw: BoundedCommandOutput) -> AfbRunOutput {
let outcome = if raw.memory_limit_hit {
AfbRunOutcome::OutOfMemory
} else {
match raw.outcome {
CommandOutcome::Exited(code) => AfbRunOutcome::Exited(code),
CommandOutcome::OutOfFuel => AfbRunOutcome::OutOfFuel,
CommandOutcome::Timeout => AfbRunOutcome::Timeout,
CommandOutcome::Trapped(msg) => AfbRunOutcome::Trapped(msg),
}
};
AfbRunOutput {
outcome,
stdout: raw.stdout,
stderr: raw.stderr,
fuel_used: raw.fuel_consumed,
}
}
fn run_wasm(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
let AfbRunRequest {
stdin,
args,
manifold,
fuel,
memory_bytes,
timeout,
} = request;
let wasm_bytes = afb.precompiled.get(PRECOMPILED_WASM_MEMBER).ok_or_else(|| {
AfterburnerError::Engine(format!(
"{} has no {PRECOMPILED_WASM_MEMBER}; run `burn compile` to produce a native WASM package",
afb.qualified_name(),
))
})?;
let vm = afterburner_wasi::embedder_vm::shared_epoch_vm()?;
let module = vm.compile(wasm_bytes, true, |_| Ok(()))?;
let mut argv = vec![afb.qualified_name()];
argv.extend(args);
let mut opts = WasiCommandOpts::new().args(argv).stdin(stdin);
if let Some(bytes) = memory_bytes {
opts = opts.max_memory_bytes(bytes as usize);
}
opts = manifold_to_wasi_opts(&manifold, opts);
let raw = vm.run_command_bounded(&module, opts, fuel, None, timeout)?;
Ok(finish(raw))
}
fn run_ruby_wasm(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
const GUEST_SRC_MOUNT: &str = "/src";
let AfbRunRequest {
stdin,
args,
manifold,
fuel,
memory_bytes,
timeout,
} = request;
let wasm_bytes = afb.precompiled.get(PRECOMPILED_WASM_MEMBER).ok_or_else(|| {
AfterburnerError::Engine(format!(
"Ruby package {} has no {PRECOMPILED_WASM_MEMBER}; re-run `burn compile` to rebuild it",
afb.qualified_name(),
))
})?;
let vm = afterburner_wasi::embedder_vm::shared_epoch_vm()?;
let module = vm.compile(wasm_bytes, true, |_| Ok(()))?;
let entry_rel = afb.manifest.package.entry.replace('\\', "/");
let guest_script = format!("{GUEST_SRC_MOUNT}/{entry_rel}");
let mut argv = vec![afb.qualified_name(), guest_script];
argv.extend(args);
let mut opts = WasiCommandOpts::new().args(argv).stdin(stdin);
if let Some(bytes) = memory_bytes {
opts = opts.max_memory_bytes(bytes as usize);
}
opts = manifold_to_wasi_opts(&manifold, opts);
let fuel = fuel.or(Some(afterburner_wasi::ruby_runner::RUBY_FUEL));
let raw = vm.run_command_bounded(&module, opts, fuel, None, timeout)?;
Ok(finish(raw))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SupportedBounds {
pub stdin: bool,
pub fuel: bool,
pub memory_bytes: bool,
pub manifold_fs_ro: bool,
pub manifold_fs_rw: bool,
pub manifold_env: bool,
pub timeout: bool,
}
pub fn supported_bounds(afb: &[u8]) -> Result<SupportedBounds> {
let parsed = Afb::from_bytes(afb)
.map_err(|error| AfterburnerError::Engine(format!("reading the .afb: {error}")))?;
Ok(bounds_for(&parsed))
}
pub fn bounds_for(afb: &Afb) -> SupportedBounds {
let target = afb.manifest.runtime.target.as_deref().unwrap_or("");
if target == afterburner_wasi::pyodide_runner::RUNTIME_TARGET {
return PYTHON_BOUNDS;
}
match afb.manifest.package.language.to_ascii_lowercase().as_str() {
"python" | "py" => PYTHON_BOUNDS,
"ruby" | "rb" if target != RUBY_WASM_RUNTIME_TARGET => SupportedBounds::default(),
_ => WASM_BOUNDS,
}
}
const PYTHON_BOUNDS: SupportedBounds = SupportedBounds {
stdin: true,
fuel: true,
memory_bytes: true,
manifold_fs_ro: false,
manifold_fs_rw: true,
manifold_env: true,
timeout: true,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartupFloor {
pub memory_bytes: u64,
pub fuel: u64,
}
pub const PYTHON_STARTUP_FLOOR: StartupFloor = StartupFloor {
memory_bytes: 256 * 1024 * 1024,
fuel: afterburner_wasi::pyodide_runner::PYODIDE_FUEL,
};
pub fn startup_floor(afb: &Afb) -> Option<StartupFloor> {
let target = afb.manifest.runtime.target.as_deref().unwrap_or("");
let language = afb.manifest.package.language.to_ascii_lowercase();
if target == afterburner_wasi::pyodide_runner::RUNTIME_TARGET
|| matches!(language.as_str(), "python" | "py")
{
return Some(PYTHON_STARTUP_FLOOR);
}
None
}
const WASM_BOUNDS: SupportedBounds = SupportedBounds {
stdin: true,
fuel: true,
memory_bytes: true,
manifold_fs_ro: true,
manifold_fs_rw: true,
manifold_env: true,
timeout: true,
};
fn refuse_unsupported(
language: &str,
request: &AfbRunRequest,
supported: SupportedBounds,
) -> Result<()> {
let mut missing: Vec<&str> = Vec::new();
if !request.stdin.is_empty() && !supported.stdin {
missing.push("stdin");
}
if request.fuel.is_some() && !supported.fuel {
missing.push("fuel");
}
if request.memory_bytes.is_some() && !supported.memory_bytes {
missing.push("memory_bytes");
}
match &request.manifold.fs {
FsAccess::None => {}
FsAccess::ReadOnly(_) if !supported.manifold_fs_ro => {
missing.push("manifold.fs (read-only)");
}
FsAccess::ReadWrite(_) if !supported.manifold_fs_rw => {
missing.push("manifold.fs (read-write)");
}
_ => {}
}
if !matches!(request.manifold.env, EnvAccess::None) && !supported.manifold_env {
missing.push("manifold.env");
}
if request.timeout.is_some() && !supported.timeout {
missing.push("timeout");
}
if missing.is_empty() {
Ok(())
} else {
Err(AfterburnerError::Engine(format!(
"{language}: {} not enforced for this package's run path; refusing rather than \
running with {} silently ignored",
missing.join(", "),
if missing.len() == 1 { "it" } else { "them" },
)))
}
}
fn run_ruby_source(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
use afterburner_wasi::ruby_runner::{RUBY_FUEL, resolve_ruby_runtime, run_ruby_afb_with};
refuse_unsupported("Ruby (source)", &request, SupportedBounds::default())?;
let entry_rel = &afb.manifest.package.entry;
if !afb.source.contains_key(entry_rel) {
return Err(AfterburnerError::Engine(format!(
"package entry {entry_rel:?} (from afb.toml) is not present under source/ in {}",
afb.qualified_name(),
)));
}
let rt = resolve_ruby_runtime()?;
let tmp_root = unique_scratch_dir("afb-run-rb-src");
let _ = std::fs::remove_dir_all(&tmp_root);
if let Err(e) = materialize_source(&tmp_root, &afb.source) {
let _ = std::fs::remove_dir_all(&tmp_root);
return Err(e);
}
let run_result = run_ruby_afb_with(&rt, &tmp_root, entry_rel, &afb.vendor);
let _ = std::fs::remove_dir_all(&tmp_root);
match run_result {
Ok(out) => Ok(AfbRunOutput {
outcome: AfbRunOutcome::Exited(out.exit_code),
stdout: out.stdout,
stderr: out.stderr,
fuel_used: out.fuel_consumed,
}),
Err(AfterburnerError::FuelExhausted) => Ok(AfbRunOutput {
outcome: AfbRunOutcome::OutOfFuel,
stdout: Vec::new(),
stderr: Vec::new(),
fuel_used: RUBY_FUEL,
}),
Err(AfterburnerError::Timeout) => Ok(AfbRunOutput {
outcome: AfbRunOutcome::Timeout,
stdout: Vec::new(),
stderr: Vec::new(),
fuel_used: RUBY_FUEL,
}),
Err(AfterburnerError::WasmTrap(msg)) => Ok(AfbRunOutput {
outcome: AfbRunOutcome::Trapped(msg),
stdout: Vec::new(),
stderr: Vec::new(),
fuel_used: 0,
}),
Err(other) => Err(other),
}
}
fn materialize_source(root: &Path, source: &BTreeMap<String, Vec<u8>>) -> Result<()> {
for (rel, data) in source {
let dest = root.join(rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
AfterburnerError::Engine(format!("creating {}: {e}", parent.display()))
})?;
}
std::fs::write(&dest, data)
.map_err(|e| AfterburnerError::Engine(format!("writing {}: {e}", dest.display())))?;
}
Ok(())
}
fn python_bounds(
language: &str,
request: AfbRunRequest,
) -> Result<afterburner_wasi::pyodide_runner::PyodideRunBounds> {
refuse_unsupported(language, &request, PYTHON_BOUNDS)?;
let AfbRunRequest {
stdin,
args: _,
manifold,
fuel,
memory_bytes,
timeout,
} = request;
let rw_preopens = match manifold.fs {
FsAccess::ReadWrite(paths) => paths
.into_iter()
.map(|p| {
let guest = p.to_string_lossy().into_owned();
(p, guest)
})
.collect(),
_ => Vec::new(),
};
let env = match manifold.env {
EnvAccess::AllowList(keys) => keys
.into_iter()
.filter_map(|k| std::env::var(&k).ok().map(|v| (k, v)))
.collect(),
EnvAccess::Full => std::env::vars().collect(),
EnvAccess::None => Vec::new(),
};
Ok(afterburner_wasi::pyodide_runner::PyodideRunBounds {
stdin: if stdin.is_empty() { None } else { Some(stdin) },
fuel,
max_memory_bytes: memory_bytes.map(|b| b as usize),
env,
rw_preopens,
timeout,
})
}
fn finish_pyodide_error(error: AfterburnerError, fuel: Option<u64>) -> AfbRunOutput {
let outcome = match &error {
AfterburnerError::FuelExhausted => AfbRunOutcome::OutOfFuel,
AfterburnerError::Timeout => AfbRunOutcome::Timeout,
_ => AfbRunOutcome::Trapped(error.to_string()),
};
AfbRunOutput {
outcome,
stdout: Vec::new(),
stderr: Vec::new(),
fuel_used: match &error {
AfterburnerError::FuelExhausted => fuel.unwrap_or(0),
_ => 0,
},
}
}
fn finish_pyodide(out: afterburner_wasi::pyodide_runner::PyodideRunOutput) -> AfbRunOutput {
let outcome = if out.memory_limit_hit {
AfbRunOutcome::OutOfMemory
} else {
AfbRunOutcome::Exited(out.exit_code)
};
AfbRunOutput {
outcome,
stdout: out.stdout,
stderr: out.stderr,
fuel_used: out.fuel_consumed,
}
}
fn run_python_source(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
use afterburner_wasi::pyodide_runner::{
PyPackage, resolve_runtime, run_pyodide_package_bounded,
};
let bounds = python_bounds("Python (source)", request)?;
let entry_source = afb
.entry_source()
.map_err(|e| AfterburnerError::Engine(format!("reading Python entry: {e}")))?;
let vendor_pip_wheels: Vec<Vec<u8>> = afb
.vendor
.iter()
.filter(|(k, _)| k.starts_with("vendor/pip/") && k.ends_with(".whl"))
.map(|(_, v)| v.clone())
.collect();
const GUEST_PKG_ROOT: &str = "/pkg";
let sys_path_dir = format!("{GUEST_PKG_ROOT}/source");
let mut files = BTreeMap::new();
for (rel, data) in &afb.source {
files.insert(format!("{GUEST_PKG_ROOT}/{rel}"), data.clone());
}
let pkg = PyPackage {
files,
sys_path_dir,
vendor_pip_wheels,
};
let rt = resolve_runtime()?;
let fuel = bounds.fuel;
match run_pyodide_package_bounded(&rt, entry_source, &pkg, bounds) {
Ok(out) => Ok(finish_pyodide(out)),
Err(e) => Ok(finish_pyodide_error(e, fuel)),
}
}
fn run_python_wasm(afb: &Afb, request: AfbRunRequest) -> Result<AfbRunOutput> {
use afterburner_wasi::pyodide_runner::{
PyPackage, reconstruct_runtime_from_afb, run_pyodide_package_bounded,
};
let bounds = python_bounds("Python (compiled)", request)?;
let entry_source = afb
.entry_source()
.map_err(|e| AfterburnerError::Engine(format!("reading Python entry: {e}")))?;
let tmp_root = unique_scratch_dir("afb-run-py-wasm");
let (rt, pip_wheel_bytes) = reconstruct_runtime_from_afb(afb, &tmp_root)?;
const GUEST_PKG_ROOT: &str = "/pkg";
let sys_path_dir = format!("{GUEST_PKG_ROOT}/source");
let mut files = BTreeMap::new();
for (rel, data) in &afb.source {
files.insert(format!("{GUEST_PKG_ROOT}/{rel}"), data.clone());
}
let pkg = PyPackage {
files,
sys_path_dir,
vendor_pip_wheels: pip_wheel_bytes,
};
let fuel = bounds.fuel;
let run_result = run_pyodide_package_bounded(&rt, entry_source, &pkg, bounds);
let _ = std::fs::remove_dir_all(&tmp_root);
match run_result {
Ok(out) => Ok(finish_pyodide(out)),
Err(e) => Ok(finish_pyodide_error(e, fuel)),
}
}
#[cfg(test)]
mod tests;