Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions kiln-runtime/src/engine/capability_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,15 @@ impl CapabilityAwareEngine {
self.inner.set_fuel(fuel);
}

/// Remaining execution fuel (instructions) after (or before) a run.
///
/// Delegates to the inner [`StacklessEngine`]. Consumed fuel for a run is
/// `budget_before - remaining_fuel()`. Enables REAL fuel reporting (SR-42)
/// instead of a static estimate. `None` if the inner engine tracks no fuel.
pub fn remaining_fuel(&self) -> Option<u64> {
self.inner.remaining_fuel()
}

/// Enable WASI support with the current capability constraints
pub fn enable_wasi(&mut self) -> Result<()> {
match self.preset {
Expand Down
96 changes: 96 additions & 0 deletions kiln-runtime/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ pub struct Memory {
pub data: Box<std::sync::Mutex<SafeMemoryHandler<LargeMemoryProvider>>>,
/// Current number of pages
pub current_pages: core::sync::atomic::AtomicU32,
/// Runtime linear-memory cap in pages (0 = unlimited). Enforced in `grow`/
/// `grow_shared` IN ADDITION TO the module-declared `ty.limits.max`, so a host
/// (e.g. kilnd `--memory <bytes>`) can bound a guest below its declared max.
/// SR-41. Set before execution; read on every grow.
pub runtime_max_pages: core::sync::atomic::AtomicU32,
/// Optional name for debugging
pub debug_name: Option<kiln_foundation::bounded::BoundedString<128>>,
/// Memory metrics for tracking access
Expand Down Expand Up @@ -312,6 +317,7 @@ impl Clone for Memory {
ty: self.ty,
data: new_data,
current_pages: AtomicU32::new(self.current_pages.load(Ordering::Relaxed)),
runtime_max_pages: AtomicU32::new(self.runtime_max_pages.load(Ordering::Relaxed)),
debug_name: self.debug_name.clone(),
metrics: cloned_metrics,
verification_level: self.verification_level,
Expand Down Expand Up @@ -485,6 +491,7 @@ impl Memory {
ty,
data: Box::new(std::sync::Mutex::new(handler)),
current_pages: core::sync::atomic::AtomicU32::new(initial_pages),
runtime_max_pages: core::sync::atomic::AtomicU32::new(0),
debug_name: None,
metrics: MemoryMetrics::new(current_size_bytes),
verification_level,
Expand Down Expand Up @@ -553,6 +560,14 @@ impl Memory {
wasm_offset_to_usize(pages).unwrap_or(0) * self.page_size_bytes()
}

/// Set a runtime linear-memory cap in PAGES (0 = unlimited). Enforced in
/// `grow`/`grow_shared` in addition to the module-declared `ty.limits.max`,
/// so a host can bound a guest below its declared maximum (kilnd `--memory`,
/// SR-41). Set before execution.
pub fn set_runtime_max_pages(&self, pages: u32) {
self.runtime_max_pages.store(pages, Ordering::Relaxed);
}

/// A reference to the memory data as a `Vec<u8>`
///
/// # Warning
Expand Down Expand Up @@ -675,6 +690,15 @@ impl Memory {
}
}

// Check against the runtime host cap (kilnd --memory, SR-41) — in addition
// to the module-declared max; 0 means unlimited.
let rt_cap = self.runtime_max_pages.load(Ordering::Relaxed);
if rt_cap != 0 && new_page_count > rt_cap {
return Err(Error::resource_limit_exceeded(
"Memory limit exceeded (--memory runtime cap)",
));
}

// Check against the absolute maximum for this page size (4GB total)
if (new_page_count as u64) > self.max_pages_for_page_size() {
return Err(Error::resource_limit_exceeded("Runtime operation error"));
Expand Down Expand Up @@ -731,6 +755,15 @@ impl Memory {
}
}

// Check against the runtime host cap (kilnd --memory, SR-41) — in addition
// to the module-declared max; 0 means unlimited.
let rt_cap = self.runtime_max_pages.load(Ordering::Relaxed);
if rt_cap != 0 && new_page_count > rt_cap {
return Err(Error::resource_limit_exceeded(
"Memory limit exceeded (--memory runtime cap)",
));
}

// Check against the absolute maximum for this page size (4GB total)
if (new_page_count as u64) > self.max_pages_for_page_size() {
return Err(Error::resource_limit_exceeded("Runtime operation error"));
Expand Down Expand Up @@ -2282,3 +2315,66 @@ impl AtomicOperations for Memory {
}
}


#[cfg(all(test, feature = "std"))]
mod sr41_cap_tests {
use super::*;
use kiln_foundation::clean_core_types::CoreMemoryType;
use kiln_foundation::types::Limits;

fn mem(min: u32, max: Option<u32>) -> Box<Memory> {
Memory::new(CoreMemoryType {
limits: Limits { min, max },
shared: false,
memory64: false,
page_size: None,
})
.unwrap()
}

/// SR-41 / #411: a runtime cap bounds memory.grow BELOW the module-declared
/// max, so kilnd --memory can sandbox a guest. 0 means unlimited.
// rivet: verifies SR-41
#[test]
fn runtime_max_pages_caps_grow_below_declared_max() {
// Declared max 10 pages; host caps at 3.
let m = mem(1, Some(10));
m.set_runtime_max_pages(3);
// 1 -> 3 is at the cap: allowed.
assert!(m.grow_shared(2).is_ok(), "grow to the cap must succeed");
assert_eq!(m.size(), 3);
// 3 -> 4 exceeds the cap: rejected, even though the declared max (10) allows it.
assert!(
m.grow_shared(1).is_err(),
"grow past the runtime cap must be rejected"
);
assert_eq!(m.size(), 3, "a rejected grow must not change the size");
}

/// SR-41: cap of 0 = unlimited (only the module-declared max applies).
// rivet: verifies SR-41
#[test]
fn runtime_cap_zero_is_unlimited() {
let m = mem(1, Some(5));
// no set_runtime_max_pages (stays 0) → declared max (5) is the only bound.
assert!(m.grow_shared(4).is_ok(), "grow to declared max must succeed with no cap");
assert_eq!(m.size(), 5);
assert!(m.grow_shared(1).is_err(), "declared max still enforced");
}

/// SR-42 / #412: peak_memory()/size_in_bytes() track REAL bytes across grow —
/// the honest data source kilnd reports (vs the old module_size*2 estimate and
/// the always-0 profiler).
// rivet: verifies SR-42
#[test]
fn peak_and_current_memory_track_real_bytes() {
let m = mem(1, Some(10));
let page = 64 * 1024;
assert_eq!(m.size_in_bytes(), page, "starts at 1 page");
assert_eq!(m.peak_memory(), page);
m.grow_shared(4).unwrap(); // 1 -> 5 pages
assert_eq!(m.size_in_bytes(), 5 * page, "current is real (5 pages)");
assert_eq!(m.peak_memory(), 5 * page, "peak is real (5 pages)");
assert!(m.peak_memory() >= m.size_in_bytes(), "peak is a high-water mark");
}
}
88 changes: 53 additions & 35 deletions kilnd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,12 @@ pub struct RuntimeStats {
pub modules_executed: u32,
/// Components executed
pub components_executed: u32,
/// Total fuel consumed
/// Total fuel consumed (REAL — engine budget minus remaining, SR-42)
pub fuel_consumed: u64,
/// Peak memory usage
/// Peak guest linear-memory usage in bytes (REAL — Memory::peak_memory, SR-42)
pub peak_memory: usize,
/// Current guest linear-memory usage in bytes at end of run (REAL, SR-42)
pub current_memory: usize,
/// WASI functions called
pub wasi_functions_called: u64,
/// Host functions registered
Expand Down Expand Up @@ -860,6 +862,22 @@ impl KilndEngine {
.instantiate(module_handle)
.map_err(|_| Error::runtime_execution_error("Failed to instantiate module"))?;

// SR-41: enforce the configured memory cap (--memory <bytes>, default 64MB)
// as a REAL runtime bound on the guest's linear memory — a memory.grow past
// it now traps (matching wasmtime with a memory limit), instead of the old
// load-time module_size*2 admission estimate that let grow escape.
{
let cap_pages =
(self.config.max_memory / (64 * 1024)).min(u32::MAX as usize) as u32;
if cap_pages > 0 {
if let Ok(inst) = engine.get_instance(instance) {
if let Ok(mem) = inst.memory(0) {
mem.0.set_runtime_max_pages(cap_pages);
}
}
}
}

// Execute function - try common entry points
let function_name = self.config.function_name.as_deref().unwrap_or("_start");
let _ = self.logger.handle_minimal_log(LogLevel::Info, "Executing function");
Expand Down Expand Up @@ -924,6 +942,26 @@ impl KilndEngine {
}
}

// SR-42: record REAL runtime resource usage (not a module_size estimate),
// while the engine + instance are still live. Consumed fuel is the budget
// minus what remains; peak memory is the guest linear memory's tracked peak.
let fuel_remaining = engine.remaining_fuel().unwrap_or(self.config.max_fuel);
let fuel_used = self.config.max_fuel.saturating_sub(fuel_remaining);
self.stats.fuel_consumed = self.stats.fuel_consumed.saturating_add(fuel_used);
let peak_mem = engine
.get_instance(instance)
.ok()
.and_then(|inst| inst.memory(0).ok())
.map(|m| m.0.peak_memory())
.unwrap_or(0);
self.stats.peak_memory = self.stats.peak_memory.max(peak_mem);
self.stats.current_memory = engine
.get_instance(instance)
.ok()
.and_then(|inst| inst.memory(0).ok())
.map(|m| m.0.size_in_bytes())
.unwrap_or(0);

self.stats.modules_executed += 1;
}

Expand Down Expand Up @@ -1031,30 +1069,10 @@ impl KilndEngine {
// Check if this is a component or module
let is_component = self.detect_component_format(&module_data)?;

// Get module size for resource estimation
#[cfg(feature = "std")]
let module_size = module_data.len();
#[cfg(not(feature = "std"))]
let module_size = module_data.len();

// Estimate resource usage
let estimated_fuel = (module_size as u64) / 10; // Conservative estimate
let estimated_memory = module_size * 2; // Memory overhead estimate

// Check limits
if estimated_fuel > self.config.max_fuel {
return Err(Error::runtime_execution_error(
"Estimated fuel exceeds maximum limit",
));
}

if estimated_memory > self.config.max_memory {
return Err(Error::new(
ErrorCategory::Resource,
codes::CAPACITY_EXCEEDED,
"Estimated memory exceeds maximum limit",
));
}
// Resource limits are enforced for REAL, not from module_size estimates:
// - fuel: engine.set_fuel(max_fuel) traps at exhaustion (execute_traditional_module).
// - memory: --memory is wired to a runtime linear-memory cap enforced in
// memory.grow (SR-41). Usage is measured after the run, not estimated (SR-42).

// Route execution based on binary type
if is_component {
Expand Down Expand Up @@ -1084,14 +1102,12 @@ impl KilndEngine {
self.execute_traditional_module(&module_data)?;
}

// Update statistics
// Update statistics. Real fuel_consumed / peak_memory / current_memory are
// recorded inside execute_traditional_module while the engine is live (SR-42);
// here we only bump the execution counters.
if is_component {
self.stats.components_executed += 1;
} else {
self.stats.modules_executed += 1;
}
self.stats.fuel_consumed += estimated_fuel;
self.stats.peak_memory = self.stats.peak_memory.max(estimated_memory);

let _ = self
.logger
Expand Down Expand Up @@ -1525,11 +1541,13 @@ pub fn run() -> Result<()> {
println!(" WASI functions called: {}", stats.wasi_functions_called);
println!(" Cross-component calls: {}", stats.cross_component_calls);

// Display memory profiling if enabled
if let Some(profiler) = engine.memory_profiler() {
// Display memory profiling if enabled — REAL guest linear-memory usage
// sampled from the instance after the run (SR-42), not the dead profiler
// counters (which were never driven on the exec path and always read 0).
if engine.memory_profiler().is_some() {
println!("Memory Profiling:");
println!(" Peak usage: {} bytes", profiler.peak_usage);
println!(" Current usage: {} bytes", profiler.current_usage);
println!(" Peak usage: {} bytes", stats.peak_memory);
println!(" Current usage: {} bytes", stats.current_memory);
}
},
Err(e) => {
Expand Down
28 changes: 28 additions & 0 deletions safety/requirements/functional-requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1057,3 +1057,31 @@ artifacts:
model: claude-opus-4-8
timestamp: 2026-07-10T00:10:00Z
release: v0.5.0

- id: SR-41
type: requirement
title: kilnd --memory <bytes> enforces a real runtime linear-memory cap (memory.grow is bounded)
status: verified
description: "kilnd --memory <bytes> must actually bound a guest's runtime linear memory so a guest cannot memory.grow past the configured cap — symmetric with --fuel (which IS enforced via engine.set_fuel at kilnd/src/lib.rs:797). Today --memory only gates a static admission estimate (module_size*2) at load (lib.rs:1042/1051) and is never wired to the engine, so memory.grow escapes the cap and the run exits 0. Measured on v0.4.1 (7a039320), differential vs wasmtime 42. Root cause (measured): the ONLY runtime bound on grow is the module's OWN declared (memory min max) max — kiln-runtime Memory::grow_shared (memory.rs:752) rejects growth past self.ty.limits.max (set from CoreMemoryType at memory.rs:445); there is NO per-instance/per-store/engine limiter and no hook for --memory (grep for limiter APIs is empty; commented-out MAX_MEMORY_BYTES at memory.rs:165). Guest linear memory is a plain StdProvider Vec (memory.rs:473), NOT under the safe_managed_alloc!/CrateId budget, so that budget cannot be the enforcement point. Fix: inject a byte cap (bytes->pages, /65536) at instantiation that clamps the effective max used by grow/grow_shared to min(module-declared-max, --memory-cap) and rejects growth past it (fail-loud trap, matching wasmtime). Differential oracle: sz.wat grows 2000 pages; wasmtime with a limit traps, kiln must too. Issue #411."
tags: [kilnd, memory, resource-limit, sandbox, bug]
fields:
upstream-ref: https://github.com/pulseengine/kiln/issues/411
provenance:
created-by: ai
model: claude-opus-4-8
timestamp: 2026-07-11T00:00:00Z
release: v0.4.2

- id: SR-42
type: requirement
title: kilnd reports REAL runtime resource usage (fuel consumed, peak/current memory, --memory-profile)
status: verified
description: "kilnd's resource reporting must reflect real execution, not static admission estimates (violates CLAUDE.md FAIL-LOUD / no-fabrication). Today: 'Fuel consumed' = module_size/10 (lib.rs:1041), 'Peak memory' = module_size*2 (lib.rs:1042/1094), and --memory-profile always prints 0 (the MemoryProfiler.record_allocation/deallocation at lib.rs:277/287 are NEVER called on the exec path — only in a test). Off by orders of magnitude; a leak/blow-up is invisible. Measured on v0.4.1. Reporting sibling of SR-41 (shared root: kilnd never samples real runtime state). Real data already exists (measured): (a) StacklessEngine::remaining_fuel() (engine.rs:11522) gives real fuel; consumed = max_fuel - remaining; MISSING only a pass-through on CapabilityAwareEngine (exposes set_fuel at capability_engine.rs:432 but not remaining_fuel). (b) Memory::peak_memory() (memory.rs:590) + size_in_bytes() (memory.rs:551) track REAL bytes, kept live by update_peak_memory() on every grow (memory.rs:609); reachable via CapabilityAwareEngine::get_instance (capability_engine.rs:1043) -> ModuleInstance::memory (module_instance.rs:133). Fix: add remaining_fuel()/fuel_consumed pass-through on CapabilityAwareEngine; in kilnd replace estimated_fuel/estimated_memory (lib.rs:1041-1042,1097-1098) with real reads after execution; drive --memory-profile from the real Memory metrics (or drop the dead MemoryProfiler and print peak_memory()/size_in_bytes() directly). Issue #412."
tags: [kilnd, observability, fuel, memory, fail-loud, bug]
fields:
upstream-ref: https://github.com/pulseengine/kiln/issues/412
provenance:
created-by: ai
model: claude-opus-4-8
timestamp: 2026-07-11T00:00:00Z
release: v0.4.2
Loading