From 0aa142b346de9dbe1e18be60dc21db4caf855f9c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 11 Jul 2026 08:16:48 +0200 Subject: [PATCH 1/2] docs(rivet): land kilnd resource-accounting bugs SR-41 (#411) + SR-42 (#412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SR-41: --memory must enforce a real runtime linear-memory cap (memory.grow bounded) — today only gates a static module_size*2 admission estimate; no engine/instance limiter exists, fix injects a byte cap clamping the effective grow max. v0.4.2. SR-42: kilnd must report REAL fuel consumed + peak/current memory + a working --memory-profile — today reports module_size/10 and module_size*2 estimates and a profiler that's never called (violates FAIL-LOUD/no-fabrication). The real data already exists (remaining_fuel(), Memory::peak_memory()/size_in_bytes()); fix is wiring. v0.4.2. Both measured on v0.4.1 with exact root-cause line numbers. Trace: SR-42 --- .../requirements/functional-requirements.yaml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/safety/requirements/functional-requirements.yaml b/safety/requirements/functional-requirements.yaml index 4b19c138..a3e0017d 100644 --- a/safety/requirements/functional-requirements.yaml +++ b/safety/requirements/functional-requirements.yaml @@ -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 enforces a real runtime linear-memory cap (memory.grow is bounded) + status: proposed + description: "kilnd --memory 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: proposed + 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 From 85a6feef745a7021ca2994ec83505bada2a80d39 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 11 Jul 2026 08:58:08 +0200 Subject: [PATCH 2/2] fix(kilnd): enforce --memory at runtime + report REAL resource usage (SR-41, SR-42, #411, #412) Two maintainer-reported bugs where kilnd faked resource accounting from module byte size (violating FAIL-LOUD/no-fabrication). SR-41 (#411): --memory now enforces a REAL runtime linear-memory cap. - kiln-runtime Memory gains a runtime_max_pages cap (AtomicU32, 0=unlimited), checked in grow/grow_shared alongside the module-declared ty.limits.max. - kilnd sets it from --memory (default 64MB) on the instance memory after instantiate, so memory.grow past the cap now traps (was: only a load-time module_size*2 admission estimate that grow escaped entirely). SR-42 (#412): kilnd reports REAL usage, not module_size arithmetic. - CapabilityAwareEngine gains remaining_fuel() (pass-through to the inner engine); consumed = budget - remaining. - execute_traditional_module records real fuel + real peak/current memory (Memory::peak_memory()/size_in_bytes()) while the engine is live. - Removed the fabricated estimated_fuel(=size/10)/estimated_memory(=size*2) and the dead MemoryProfiler path; --memory-profile now prints the real numbers. Verified against the issue reproducers: grow-to-105MB traps at the 64MB default and succeeds under --memory 256MB (real 104923136 bytes reported, real fuel=10); sz.wat's 128MB grow traps under --memory 64MB. 3 unit oracles (rivet: verifies SR-41/SR-42); kiln-runtime 97 pass, kilnd 13 pass, no regressions. Trace: SR-41 --- kiln-runtime/src/engine/capability_engine.rs | 9 ++ kiln-runtime/src/memory.rs | 96 +++++++++++++++++++ kilnd/src/lib.rs | 88 ++++++++++------- .../requirements/functional-requirements.yaml | 4 +- 4 files changed, 160 insertions(+), 37 deletions(-) diff --git a/kiln-runtime/src/engine/capability_engine.rs b/kiln-runtime/src/engine/capability_engine.rs index 1452b74e..610ece53 100644 --- a/kiln-runtime/src/engine/capability_engine.rs +++ b/kiln-runtime/src/engine/capability_engine.rs @@ -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 { + self.inner.remaining_fuel() + } + /// Enable WASI support with the current capability constraints pub fn enable_wasi(&mut self) -> Result<()> { match self.preset { diff --git a/kiln-runtime/src/memory.rs b/kiln-runtime/src/memory.rs index f549bdc4..0b032f97 100644 --- a/kiln-runtime/src/memory.rs +++ b/kiln-runtime/src/memory.rs @@ -269,6 +269,11 @@ pub struct Memory { pub data: Box>>, /// 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 `) 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>, /// Memory metrics for tracking access @@ -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, @@ -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, @@ -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` /// /// # Warning @@ -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")); @@ -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")); @@ -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) -> Box { + 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"); + } +} diff --git a/kilnd/src/lib.rs b/kilnd/src/lib.rs index 910a5262..81e9eafa 100644 --- a/kilnd/src/lib.rs +++ b/kilnd/src/lib.rs @@ -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 @@ -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 , 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"); @@ -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; } @@ -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 { @@ -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 @@ -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) => { diff --git a/safety/requirements/functional-requirements.yaml b/safety/requirements/functional-requirements.yaml index a3e0017d..6a02beae 100644 --- a/safety/requirements/functional-requirements.yaml +++ b/safety/requirements/functional-requirements.yaml @@ -1061,7 +1061,7 @@ artifacts: - id: SR-41 type: requirement title: kilnd --memory enforces a real runtime linear-memory cap (memory.grow is bounded) - status: proposed + status: verified description: "kilnd --memory 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: @@ -1075,7 +1075,7 @@ artifacts: - id: SR-42 type: requirement title: kilnd reports REAL runtime resource usage (fuel consumed, peak/current memory, --memory-profile) - status: proposed + 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: