Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}");
}