plugin/host/tests/the_meter_fuel_ceiling.rs
//! **?17 — what a Meter guest actually costs, and the ceiling that follows.**
//!
//! The proposal on record was 10e6 fuel / 250 ms for a Meter guest, one or two
//! orders below the package handler's 1e9 / 30 s, with a slow verdict refusing.
//! The refusing half is settled and implemented (`meter_gate.rs`: `Err` →
//! undecided → refuse). This file is about the NUMBER, and about why the number
//! could not stay a proposal:
//!
//! **the meter gate now runs on PUT.** A fuel ceiling that fires is therefore
//! not a safety bound on a background reading — it is an availability control on
//! every push, because the refusal is the upload's refusal. An over-tight
//! ceiling would be a self-inflicted outage on the one path a repository exists
//! for.
//!
//! So the ceiling is 100x a MEASUREMENT, not a guess, and this file is the
//! measurement. It runs the REAL `holger_plugin_allocation_wasm.wasm` that
//! `cargo xtask build-wasm-plugins` produces — never a mock, never a skip — asks
//! wasmtime how much fuel each call consumed, and prints the table under
//! `--nocapture`.
//!
//! ```text
//! cargo test -p holger-plugin-host --features meter-guests,wasm-plugins \
//! --test the_meter_fuel_ceiling -- --nocapture
//! ```
mod common;
use std::path::PathBuf;
use std::sync::Arc;
use holger_plugin_abi::v2::{
exports, imports, AbiRange, Capability, ClockSource, Enforcement, GuestKind, MeterFacts,
MeterScope, ReadingValue, WireAuditRecord, WireCallContext, WireInstant, WireManifest2,
WireMeterReading, WireVerb, METER_FUEL_CEILING, METER_FUEL_MULTIPLE,
};
use holger_plugin_host::backend::SharedStore;
use holger_plugin_host::meter::{MeterHostServices, MeterRuntime};
use holger_plugin_host::meter_wasm::WasmMeter;
use holger_plugin_host::{limits::narrowed_to, DirStore, PluginLimits, PluginPolicy};
const MODULE: &str = "holger_plugin_allocation_wasm.wasm";
const GRANT: &[Capability] = &[Capability::UsageRead, Capability::StoreList];
// ── the host the guest measures against ───────────────────────────────────
/// A host that answers the product-scope storage meters and one tenant's two,
/// so `capacity`, `selftest` and `read` all have real rows to decode. The
/// decoding is where a guest's fuel goes, so the answer's SIZE is the load.
struct Counting {
/// How many tenant readings `usage_read` hands back for a named repository.
rows: usize,
}
fn reading(meter: &str, v: u64) -> WireMeterReading {
WireMeterReading {
meter: meter.into(),
scope: MeterScope::Product,
enforcement: Enforcement::Measured,
value: ReadingValue::Counted(v),
}
}
impl MeterHostServices for Counting {
fn usage_read(&self, repository: &str) -> Result<Vec<WireMeterReading>, String> {
use holger_plugin_abi::v2::meters;
if repository.is_empty() {
return Ok(vec![
reading(meters::STORAGE_TOTAL_BYTES, 8 * 1024 * 1024 * 1024),
reading(meters::STORAGE_SERVABLE_BYTES, 5 * 1024 * 1024 * 1024),
reading(meters::STORAGE_USED_BYTES, 3 * 1024 * 1024 * 1024),
]);
}
Ok((0..self.rows)
.map(|i| reading(meters::ARTIFACT_BYTES, i as u64))
.collect())
}
fn audit_write(&self, _record: &WireAuditRecord) -> Result<(), String> {
Ok(())
}
fn clock(&self) -> Result<WireInstant, String> {
Ok(WireInstant {
unix_nanos: 1,
source: ClockSource::MonotonicSinceBoot,
trusted_until_unix_nanos: 0,
})
}
}
/// A scratch directory that removes itself. Local because this crate has no
/// tempfile dependency and a fuel measurement must not add one.
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let p = std::env::temp_dir().join(format!(
"holger-fuel-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("scratch dir");
Self(p)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The real module, loaded over a store holding `keys` blobs and a host that
/// answers `rows` tenant readings. Both are the LOAD the fuel is measured under.
fn loaded(keys: usize, rows: usize) -> (Scratch, WasmMeter) {
let dir = Scratch::new("store");
for i in 0..keys {
std::fs::write(dir.0.join(format!("blob-{i:05}")), vec![7u8; 64]).expect("probe blob");
}
let store: SharedStore = Arc::new(DirStore::new(&dir.0));
let modules = common::plugin_dir_with_modules(&[MODULE]);
let meter = WasmMeter::load_with(
modules.join(MODULE),
store,
Arc::new(Counting { rows }),
&PluginPolicy::default(),
GRANT,
)
.expect("the open guest loads under its own grant");
(dir, meter)
}
fn context(repo: &str) -> WireCallContext {
WireCallContext {
abi: 2,
repository: repo.to_string(),
verb: WireVerb::Put,
principal: None,
artifact: None,
now: None,
}
}
/// Run `f`, then report what it burned.
fn cost(m: &WasmMeter, f: impl FnOnce(&WasmMeter)) -> u64 {
f(m);
m.last_call_fuel()
}
// ── THE MEASUREMENT ───────────────────────────────────────────────────────
/// **The table ?17's ceiling is arithmetic on.**
///
/// Every op the Meter `KindSpec` declares, over three loads, plus the two
/// load-time exports (`plugin_abi`, `plugin_manifest`). It asserts nothing about
/// a particular number — numbers move with the compiler — but it FAILS if the
/// worst of them is not comfortably inside the ceiling, which is the property
/// the ruling actually bought.
#[test]
fn the_open_guests_real_calls_are_measured_and_sit_far_under_the_ceiling() {
println!(
"\n=== ?17 · fuel per call, holger_plugin_allocation_wasm.wasm, \
ceiling {METER_FUEL_CEILING} ({METER_FUEL_MULTIPLE}x the measured peak) ===\n"
);
println!("{:<18} {:>12} {:>10} {}", "op", "fuel", "keys/rows", "load");
let mut peak = 0u64;
// (keys in the store, tenant rows the host answers, a word for the table)
for (keys, rows, load) in [
(0usize, 2usize, "empty store"),
(64, 16, "64 blobs"),
(1024, 128, "1024 blobs"),
] {
let (_d, m) = loaded(keys, rows);
let mut row = |name: &str, fuel: u64| {
println!(
"{name:<18} {fuel:>12} {:>10} {load}",
format!("{keys}/{rows}")
);
peak = peak.max(fuel);
};
// The two the LOAD itself pays, once, before any op.
row(
exports::PLUGIN_ABI,
cost(&m, |m| assert!(m.abi() >= 2, "negotiated ABI")),
);
row(
exports::MANIFEST,
cost(&m, |m| assert!(!m.manifest().name.is_empty())),
);
// The seven Meter ops, each one `plugin_call` in its own store.
row(
"meters",
cost(&m, |m| assert_eq!(m.meters().expect("meters").len(), 7)),
);
row(
"read",
cost(&m, |m| {
assert!(!m.read("releases").expect("read").is_empty())
}),
);
row(
"capacity",
cost(&m, |m| {
assert!(m.capacity().expect("capacity").total_bytes > 0)
}),
);
row(
"selftest",
cost(&m, |m| assert!(m.selftest().expect("selftest").passed)),
);
row(
"evaluate",
cost(&m, |m| assert!(m.evaluate(&context("releases")).is_ok())),
);
row(
"accept_fact",
cost(&m, |m| assert!(m.accept_fact(&[1, 2, 3]).is_err())),
);
row(
"describe_fact",
cost(&m, |m| assert!(m.describe_fact("releases").is_err())),
);
assert_eq!(
m.fuel_ceiling(),
METER_FUEL_CEILING,
"the loaded meter is not running on the Meter kind's ceiling"
);
assert!(
m.peak_call_fuel() > 0,
"nothing was measured at all — wasmtime reported no fuel consumption, \
which means the accounting is not wired, not that the guest is free"
);
}
println!(
"\npeak {peak} · ceiling {METER_FUEL_CEILING} · headroom {}x",
METER_FUEL_CEILING / peak.max(1)
);
// THE assertion. The ruling asked for at least 100x above the measured
// maximum, because the bound exists to stop a guest that does not terminate
// and not to police an honest guest that got slower on a bigger repository.
assert!(
peak.saturating_mul(METER_FUEL_MULTIPLE) <= METER_FUEL_CEILING,
"the open guest's worst call burns {peak}, and {METER_FUEL_MULTIPLE}x that is more \
than the ceiling {METER_FUEL_CEILING}. Re-measure and move \
`MEASURED_FUEL_PEAK` in plugin/abi/src/v2.rs — the ceiling is arithmetic on that \
number and must not be left behind by the guest it was measured from."
);
}
/// The ceiling is the KIND's, and an operator may narrow it but not widen it.
#[test]
fn the_kinds_ceiling_binds_and_a_bigger_config_cannot_raise_it() {
let (_d, m) = loaded(0, 2);
assert_eq!(m.fuel_ceiling(), METER_FUEL_CEILING);
assert_eq!(GuestKind::Meter.fuel(), METER_FUEL_CEILING);
// The package handler keeps its own, far larger, budget: kinds do not share
// a number any more, which is the point of the field.
assert!(
GuestKind::PackageHandler.fuel() > GuestKind::Meter.fuel(),
"the Meter ceiling is not below the package handler's"
);
// An operator asking for MORE than the kind allows gets the kind's number.
let generous = PluginLimits {
fuel: u64::MAX,
..Default::default()
};
assert_eq!(
narrowed_to(&generous, GuestKind::Meter).fuel,
METER_FUEL_CEILING
);
// An operator asking for LESS keeps their own, tighter, number.
let tight = PluginLimits {
fuel: 1_000,
..Default::default()
};
assert_eq!(narrowed_to(&tight, GuestKind::Meter).fuel, 1_000);
}
// ── THE REFUSAL ───────────────────────────────────────────────────────────
/// A hand-written ABI v2 Meter guest whose `plugin_call` never returns.
///
/// WAT rather than a crate, for the same reason `resource_bounds_are_named_refusals.rs`
/// uses WAT: the point is to express what no honest `Meter` implementation can be
/// made to do. Its manifest is the REAL TLV encoding, produced by
/// `WireManifest2::to_bytes` and laid into a data segment, so the module gets
/// through every gate the loader runs and is refused by the FUEL bound and
/// nothing earlier.
fn runaway_module() -> Vec<u8> {
let manifest = WireManifest2 {
abi: AbiRange::new(2, 2),
kind: GuestKind::Meter,
name: "runaway".to_string(),
// The kind's mandatory capability, and the one import below.
requires: vec![Capability::UsageRead],
handler: None,
meter: Some(MeterFacts {
accepts_facts: false,
}),
identity: None,
unknown_optional: Vec::new(),
}
.to_bytes();
let escaped: String = manifest.iter().map(|b| format!("\\{b:02x}")).collect();
let abi_packed = (2u64 << 32) | 2u64;
let wat = format!(
r#"(module
(import "{module}" "{usage_read}" (func $usage_read (param i32 i32) (result i64)))
(memory (export "memory") 1)
(data (i32.const 16) "{escaped}")
(func (export "alloc") (param i32) (result i32) (i32.const 2048))
(func (export "dealloc") (param i32 i32))
(func (export "result_len") (result i32) (i32.const {len}))
(func (export "plugin_abi") (result i64) (i64.const {abi_packed}))
(func (export "plugin_manifest") (result i32) (i32.const 16))
(func (export "plugin_call") (param i32 i32 i32) (result i64)
(loop $forever (br $forever))
(i64.const 0))
)"#,
module = imports::MODULE,
usage_read = imports::USAGE_READ,
len = manifest.len(),
);
wat::parse_str(&wat).expect("the runaway guest assembles")
}
/// **A guest that burns past the ceiling is REFUSED, by name.**
///
/// Not "eventually returns something", not a hung request thread: a named
/// refusal carrying the fuel error code, the export that ran away, and the
/// ceiling it ran past — and the ceiling it prints is the METER kind's, which is
/// what proves the narrowing in `load_with` is the thing that bound this call.
#[test]
fn a_meter_guest_that_burns_past_the_ceiling_is_refused_by_name() {
let dir = Scratch::new("runaway");
let path = dir.0.join("runaway.wasm");
std::fs::write(&path, runaway_module()).expect("write the runaway module");
let store: SharedStore = Arc::new(DirStore::new(dir.0.join("data")));
let m = WasmMeter::load_with(
&path,
store,
Arc::new(Counting { rows: 1 }),
&PluginPolicy::default(),
&[Capability::UsageRead],
)
.expect("the runaway guest LOADS — its manifest and imports are honest; only its work is not");
let started = std::time::Instant::now();
let err = m
.evaluate(&context("releases"))
.expect_err("an endless evaluate returned a verdict");
let elapsed = started.elapsed();
assert!(
err.contains(holger_errcode::PLUGIN_FUEL_EXHAUSTED.code),
"refused, but not by the NAMED fuel bound: {err}"
);
assert!(
err.contains(exports::PLUGIN_CALL),
"the refusal does not name the export: {err}"
);
assert!(
err.contains(&METER_FUEL_CEILING.to_string()),
"the refusal does not print the METER ceiling {METER_FUEL_CEILING}, so it is not the \
kind's bound that fired: {err}"
);
assert!(
elapsed < std::time::Duration::from_secs(20),
"the fuel bound took {elapsed:?} — the deadline, not the fuel, is what ended it"
);
println!("runaway refused in {elapsed:?}: {err}");
}