crates/guest-host/src/limits.rs
//! **What a loaded module may spend before the host refuses it, by name.**
//!
//! # Two CPU bounds, because they measure different things
//!
//! | bound | what it counts | why it is here |
//! |---|---|---|
//! | **fuel** (`Config::consume_fuel`) | wasm instructions executed | DETERMINISTIC. The same module on the same input refuses at the same instruction on a loaded machine and an idle one, so a runaway is reproducible instead of a flaky timeout. |
//! | **epoch** (`Config::epoch_interruption`) | wall-clock ticks | The BACKSTOP. Fuel does not tick while the module is parked inside a host import waiting on a slow disk or a slow HTTP peer. Only the clock catches that. |
//!
//! Neither replaces the other.
//!
//! # Zero is a misconfiguration, never "unlimited"
//!
//! [`PluginLimits::validate`] REFUSES a zero. In every config language a host
//! reads, an unset number arrives as `0`, and the single most likely way to
//! silently reinstate an unbounded host is to let `0` mean "no ceiling". So it
//! is named and refused at boot rather than interpreted.
//!
//! # Without the `wasmtime` feature
//!
//! The vocabulary — [`PluginLimits`], [`Bound`], the defaults, `validate` —
//! compiles everywhere, so a config validator or a signer can name a limit
//! without carrying a runtime. The engine config, the limiter and the ticker
//! need the runtime and are behind the feature.
use std::time::Duration;
/// Default CPU fuel for ONE call into a module: a billion instructions.
///
/// Four orders of magnitude of headroom over honest work (holger's package
/// handlers spend six figures on a real fetch) and still under a second of pure
/// compute for a `loop {}`. A kind of guest that does less narrows it.
pub const DEFAULT_FUEL: u64 = 1_000_000_000;
/// Default linear-memory ceiling for one store: 256 MiB. Sized against what a
/// guest legitimately holds, not against what the machine has.
pub const DEFAULT_MAX_MEMORY_BYTES: usize = 256 * 1024 * 1024;
/// Default table-element ceiling. Six figures is far past any real guest and
/// far short of a table grown as an allocation channel.
pub const DEFAULT_MAX_TABLE_ELEMENTS: usize = 100_000;
/// Default wall-clock deadline for one call into a module.
pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30);
/// How many epoch ticks make up one call deadline. The ticker fires
/// `call_timeout / TICKS_PER_DEADLINE`, so the deadline is accurate to one
/// tick; eight is close enough that a 30 s deadline is never a 60 s hang.
pub const TICKS_PER_DEADLINE: u64 = 8;
/// Everything one loaded module is allowed to spend.
///
/// [`Default`] is the SAFE answer on purpose: a caller that never configures
/// anything gets bounded execution, because the failure being closed here is a
/// host that was unbounded because nobody said otherwise.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginLimits {
/// Wasm instructions one call may execute.
pub fuel: u64,
/// Bytes of linear memory one store may hold.
pub max_memory_bytes: usize,
/// Elements one store's tables may hold.
pub max_table_elements: usize,
/// Wall-clock ceiling on one call, including time spent in host imports.
pub call_timeout: Duration,
}
impl Default for PluginLimits {
fn default() -> Self {
Self {
fuel: DEFAULT_FUEL,
max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
max_table_elements: DEFAULT_MAX_TABLE_ELEMENTS,
call_timeout: DEFAULT_CALL_TIMEOUT,
}
}
}
/// A limit that is zero. `Display` says which and how to fix it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LimitZero {
/// `fuel` | `max_memory_bytes` | `max_table_elements` | `call_timeout`.
pub field: &'static str,
}
impl std::fmt::Display for LimitZero {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"wasm guest limit `{}` is 0. Zero is NOT \"unlimited\" here — it is refused, because an \
unset config value arrives as 0 and reading it as \"no ceiling\" is exactly the \
unbounded host these limits exist to close. Set a real number, or leave the whole \
limits block out to get the safe defaults (fuel {DEFAULT_FUEL}, memory \
{DEFAULT_MAX_MEMORY_BYTES} bytes, {DEFAULT_MAX_TABLE_ELEMENTS} table elements, {}s \
deadline).",
self.field,
DEFAULT_CALL_TIMEOUT.as_secs()
)
}
}
impl std::error::Error for LimitZero {}
impl PluginLimits {
/// Refuse a limit that is zero, naming the field. See the module docs.
pub fn validate(&self) -> Result<(), LimitZero> {
if self.fuel == 0 {
return Err(LimitZero { field: "fuel" });
}
if self.max_memory_bytes == 0 {
return Err(LimitZero { field: "max_memory_bytes" });
}
if self.max_table_elements == 0 {
return Err(LimitZero { field: "max_table_elements" });
}
if self.call_timeout.is_zero() {
return Err(LimitZero { field: "call_timeout" });
}
Ok(())
}
/// **The tighter of these limits and a ceiling** on the two CPU axes.
///
/// A guest KIND brings its own ceiling (holger's meter runs on the push
/// path with a tank a hundred times its measured worst call; a monetize
/// product answers a five-minute poll). This narrows and never widens: an
/// operator may bring a guest further down, never up past the kind's
/// ceiling by writing a bigger number in a config file. Memory and tables
/// are untouched — they are about how much of the machine a module may
/// hold, a property of the box and not of the kind.
pub fn narrowed(&self, fuel_ceiling: u64, call_timeout_ceiling: Duration) -> Self {
Self {
fuel: self.fuel.min(fuel_ceiling),
call_timeout: self.call_timeout.min(call_timeout_ceiling),
max_memory_bytes: self.max_memory_bytes,
max_table_elements: self.max_table_elements,
}
}
/// Epoch ticks one call is given, and the ticker period that makes them
/// add up to [`PluginLimits::call_timeout`].
pub fn epoch_schedule(&self) -> (u64, Duration) {
let period = self.call_timeout / TICKS_PER_DEADLINE as u32;
// A sub-millisecond ticker would spin a core for no extra accuracy.
(TICKS_PER_DEADLINE, period.max(Duration::from_millis(1)))
}
}
/// Which ceiling a call hit. Carried instead of a bare string so a host can put
/// its own code on the refusal, and so an operator reading a log can tell a
/// runaway from an over-large guest without guessing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bound {
/// Burned its whole fuel allowance.
Fuel,
/// Passed its wall-clock deadline.
Deadline,
/// Asked for linear memory past the ceiling.
Memory,
/// Asked for table elements past the ceiling.
Table,
}
impl Bound {
/// `fuel` | `deadline` | `memory` | `table`.
pub fn name(self) -> &'static str {
match self {
Bound::Fuel => "fuel",
Bound::Deadline => "deadline",
Bound::Memory => "memory",
Bound::Table => "table",
}
}
/// The refusal: `<path>: <export>: <what and how to fix it>`, quoting the
/// limit that was hit — the operator's next move is to raise it or throw
/// the module out, and they cannot do either without knowing what it was.
pub fn refusal(self, path: &str, export: &str, limits: &PluginLimits) -> String {
match self {
Bound::Fuel => format!(
"{path}: {export} burned its whole CPU fuel allowance ({} instructions) and was \
cut off. An honest guest spends a tiny fraction of that, so this is a runaway \
or an infinite loop, not a busy one. The module is NOT unloaded; the next call \
starts with a full tank.",
limits.fuel
),
Bound::Deadline => format!(
"{path}: {export} passed its wall-clock deadline ({:?}) and was interrupted. \
Unlike the fuel bound this also counts time spent inside a host import, so a \
module blocked on storage or on a slow peer lands here rather than hanging \
the caller.",
limits.call_timeout
),
Bound::Memory => format!(
"{path}: {export} asked to grow its linear memory past the ceiling ({} bytes). \
Refused by name rather than left as the -1 the guest sees from memory.grow — \
a module that turned a refused allocation into \"nothing here\" would make a \
memory ceiling look like an empty answer.",
limits.max_memory_bytes
),
Bound::Table => format!(
"{path}: {export} asked to grow a table past the ceiling ({} elements).",
limits.max_table_elements
),
}
}
}
#[cfg(feature = "wasmtime")]
pub use runtime::{BoundedLimiter, EpochTicker};
#[cfg(feature = "wasmtime")]
mod runtime {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use wasmtime::{Engine, ResourceLimiter, StoreLimits, StoreLimitsBuilder};
impl PluginLimits {
/// The wasmtime `Config` these limits require. Both CPU bounds are
/// switched on at the *engine* level — they cannot be added to a
/// `Store` later — which is why a host built on this crate can never
/// be built from `Engine::default()`.
pub fn engine_config(&self) -> wasmtime::Config {
let mut config = wasmtime::Config::new();
config.consume_fuel(true);
config.epoch_interruption(true);
config
}
/// The per-store resource limiter for these limits.
pub fn limiter(&self) -> BoundedLimiter {
BoundedLimiter {
inner: StoreLimitsBuilder::new()
.memory_size(self.max_memory_bytes)
.table_elements(self.max_table_elements)
.build(),
refused: None,
max_memory_bytes: self.max_memory_bytes,
max_table_elements: self.max_table_elements,
}
}
}
/// `StoreLimits` that REMEMBERS the refusal.
///
/// `StoreLimits` on its own answers `memory.grow` with `false`, which the
/// guest reads as `-1` and typically turns into its own `unreachable` — so
/// the host would see "the module trapped" and have nothing to say about
/// why. Recording the refusal here is what lets a host name the ceiling
/// afterwards instead of reporting a generic trap.
pub struct BoundedLimiter {
inner: StoreLimits,
refused: Option<Bound>,
max_memory_bytes: usize,
max_table_elements: usize,
}
impl BoundedLimiter {
/// The ceiling this store hit, if it hit one. Read after a call returns.
pub fn refused(&self) -> Option<Bound> {
self.refused
}
}
impl ResourceLimiter for BoundedLimiter {
fn memory_growing(
&mut self,
current: usize,
desired: usize,
maximum: Option<usize>,
) -> wasmtime::Result<bool> {
let allowed = self.inner.memory_growing(current, desired, maximum)?;
if !allowed || desired > self.max_memory_bytes {
self.refused = Some(Bound::Memory);
}
Ok(allowed)
}
fn table_growing(
&mut self,
current: usize,
desired: usize,
maximum: Option<usize>,
) -> wasmtime::Result<bool> {
let allowed = self.inner.table_growing(current, desired, maximum)?;
if !allowed || desired > self.max_table_elements {
self.refused = Some(Bound::Table);
}
Ok(allowed)
}
}
/// The thread that advances an `Engine`'s epoch, so a wall-clock deadline
/// is something other than a promise.
///
/// `epoch_interruption(true)` alone interrupts nothing: wasmtime checks the
/// engine's epoch counter against the store's deadline, and the counter
/// only moves when somebody moves it. One ticker per loaded module; a
/// module mounted under N names shares one ticker (`Arc`).
pub struct EpochTicker {
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl EpochTicker {
/// Start ticking `engine` every `period`.
pub fn start(engine: &Engine, period: Duration) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let engine = engine.clone();
let flag = Arc::clone(&stop);
// A32-14, stays a thread: the wasm epoch ticker that makes the wall-clock ceiling bite; stopped by its flag on drop.
let handle = std::thread::Builder::new()
.name("guest-host-epoch".to_string())
.spawn(move || {
while !flag.load(Ordering::Relaxed) {
std::thread::sleep(period);
engine.increment_epoch();
}
})
.ok();
Self { stop, handle }
}
}
impl Drop for EpochTicker {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// Not joined: the thread is asleep for up to one period and joining
// would stall an unload for that long. The flag is the contract; the
// `Engine` clone it holds keeps the engine alive until it notices.
let _ = self.handle.take();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The defaults are the SAFE ones. A host whose defaults are unbounded is
/// a host that is unbounded, because nobody configures what already works.
#[test]
fn the_defaults_are_bounded() {
let l = PluginLimits::default();
l.validate().expect("the defaults must be a valid configuration");
assert!(l.fuel > 0 && l.max_memory_bytes > 0 && l.max_table_elements > 0);
assert!(!l.call_timeout.is_zero());
}
/// Zero is refused, by name, for every field — not read as "unlimited".
#[test]
fn a_zero_limit_is_refused_by_name_and_never_means_unlimited() {
for (field, limits) in [
("fuel", PluginLimits { fuel: 0, ..Default::default() }),
("max_memory_bytes", PluginLimits { max_memory_bytes: 0, ..Default::default() }),
("max_table_elements", PluginLimits { max_table_elements: 0, ..Default::default() }),
("call_timeout", PluginLimits { call_timeout: Duration::ZERO, ..Default::default() }),
] {
let err = limits.validate().expect_err("a zero limit was accepted");
assert_eq!(err.field, field);
assert!(err.to_string().contains(field), "the refusal does not name the field: {err}");
}
}
/// The deadline is divided into ticks that add up to it.
#[test]
fn the_epoch_schedule_adds_up_to_the_deadline() {
let limits = PluginLimits { call_timeout: Duration::from_secs(8), ..Default::default() };
let (ticks, period) = limits.epoch_schedule();
assert_eq!(period * ticks as u32, limits.call_timeout);
}
/// Narrowing takes the tighter number on each CPU axis and leaves memory alone.
#[test]
fn narrowing_never_widens() {
let generous = PluginLimits { fuel: u64::MAX, call_timeout: Duration::from_secs(600), ..Default::default() };
let n = generous.narrowed(1_000, Duration::from_secs(5));
assert_eq!((n.fuel, n.call_timeout), (1_000, Duration::from_secs(5)));
let tight = PluginLimits { fuel: 10, call_timeout: Duration::from_millis(50), ..Default::default() };
let n = tight.narrowed(1_000, Duration::from_secs(5));
assert_eq!((n.fuel, n.call_timeout), (10, Duration::from_millis(50)));
assert_eq!(n.max_memory_bytes, tight.max_memory_bytes);
}
/// Every bound names itself, the export, the module and the limit hit.
#[test]
fn every_refusal_names_module_export_and_limit() {
let limits = PluginLimits::default();
let mut names = Vec::new();
for b in [Bound::Fuel, Bound::Deadline, Bound::Memory, Bound::Table] {
names.push(b.name());
let msg = b.refusal("/plugins/x.wasm", "plugin_call", &limits);
assert!(msg.contains("plugin_call") && msg.contains("/plugins/x.wasm"), "{msg}");
}
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), 4, "two bounds share a name");
assert!(Bound::Fuel.refusal("m", "e", &limits).contains(&DEFAULT_FUEL.to_string()));
assert!(Bound::Memory.refusal("m", "e", &limits).contains(&DEFAULT_MAX_MEMORY_BYTES.to_string()));
}
/// The engine config actually switches both CPU bounds on.
#[cfg(feature = "wasmtime")]
#[test]
fn the_engine_config_enables_both_cpu_bounds() {
let limits = PluginLimits::default();
let engine = wasmtime::Engine::new(&limits.engine_config()).expect("engine");
let mut store = wasmtime::Store::new(&engine, ());
store.set_fuel(limits.fuel).expect("fuel is not enabled on this engine");
store.set_epoch_deadline(1);
}
}