diff --git a/crates/core/src/ir/lowerer.rs b/crates/core/src/ir/lowerer.rs index c438087..cf9a1d0 100644 --- a/crates/core/src/ir/lowerer.rs +++ b/crates/core/src/ir/lowerer.rs @@ -3079,7 +3079,7 @@ mod tests { let module = lower_source("type Box { Box }\nfn main() { Box }"); let wat = module.emit_wat().expect("emit managed constructor"); - assert!(wat.contains("(memory 1)")); + assert!(wat.contains("(memory 1 256)")); assert!(wat.contains("(data (memory 0)")); } diff --git a/crates/core/src/runtime.rs b/crates/core/src/runtime.rs index 8bdb247..da1d920 100644 --- a/crates/core/src/runtime.rs +++ b/crates/core/src/runtime.rs @@ -1,6 +1,9 @@ use crate::ClosureConstants; use crate::wasm::{RuntimeHelperFragment, fragments, runtime_helper_fragments_from_block}; +pub const WASM_PAGE_SIZE: u32 = 65_536; +pub const DEFAULT_MEMORY_MAX_PAGES: u32 = 256; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Layout { pub word_size: u32, @@ -63,15 +66,28 @@ pub struct RuntimeConfig { pub layout: Layout, pub static_data_start: u32, pub heap_start: u32, + pub memory_max_pages: u32, } impl RuntimeConfig { - pub const DEFAULT: Self = Self { layout: Layout::DEFAULT, static_data_start: 1024, heap_start: 4096 }; + pub const DEFAULT: Self = Self { + layout: Layout::DEFAULT, + static_data_start: 1024, + heap_start: 4096, + memory_max_pages: DEFAULT_MEMORY_MAX_PAGES, + }; + + pub fn memory_limit_bytes(self) -> u32 { + self.memory_max_pages + .checked_mul(WASM_PAGE_SIZE) + .expect("runtime memory limit must fit in wasm32 address space") + } pub fn runtime_helper_fragments(self) -> Vec { let alloc_helper = fragments::allocation::ALLOC_HELPER .replace("{alignment_mask}", &(self.layout.alignment - 1).to_string()) .replace("{alignment}", &self.layout.alignment.to_string()) + .replace("{heap_limit}", &self.memory_limit_bytes().to_string()) .replace("{allocation_failure_offset}", "64"); let managed_value_helpers = fragments::managed_values::MANAGED_VALUE_HELPERS .replace( diff --git a/crates/core/src/wasm/codegen.rs b/crates/core/src/wasm/codegen.rs index 4cfbd9a..44aa298 100644 --- a/crates/core/src/wasm/codegen.rs +++ b/crates/core/src/wasm/codegen.rs @@ -586,6 +586,7 @@ impl<'a> StructuredEmitter<'a> { } } if needs_allocation(function) { + self.runtime_helper_roots.insert("__allocation_fail".into()); let id = LocalId((structured.params.len() + structured.locals.len()) as u32); structured .locals @@ -1038,7 +1039,7 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32GtU); out.push(Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self.allocation_failure_body(vec![Instruction::I32Const(bytes as i32)]), else_body: Vec::new(), }); out.push(Instruction::LocalGet { local: ptr, type_: ValueType::I32 }); @@ -1050,7 +1051,7 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32GtU); out.push(Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self.allocation_failure_body(vec![Instruction::I32Const(bytes as i32)]), else_body: Vec::new(), }); out.push(Instruction::LocalGet { local: end, type_: ValueType::I32 }); @@ -1060,6 +1061,8 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32And); out.push(Instruction::LocalSet { local: end, type_: ValueType::I32 }); + self.check_heap_limit(end, vec![Instruction::I32Const(bytes as i32)], out); + out.push(Instruction::LocalGet { local: end, type_: ValueType::I32 }); out.push(Instruction::MemorySize(memory)); out.push(Instruction::I32Const(65536)); @@ -1083,7 +1086,7 @@ impl<'a> StructuredEmitter<'a> { Instruction::I32Eq, Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self.allocation_failure_body(vec![Instruction::I32Const(bytes as i32)]), else_body: Vec::new(), }, ], @@ -1096,6 +1099,33 @@ impl<'a> StructuredEmitter<'a> { Ok(()) } + fn check_heap_limit(&self, end: LocalId, size: Vec, out: &mut Vec) { + out.push(Instruction::LocalGet { local: end, type_: ValueType::I32 }); + out.push(Instruction::I32Const(self.config.memory_limit_bytes() as i32)); + out.push(Instruction::I32GtU); + out.push(Instruction::If { + type_: BlockType::empty(), + then_body: self.allocation_failure_body(size), + else_body: Vec::new(), + }); + } + + fn allocation_failure_body(&self, size: Vec) -> Vec { + let mut body = size; + body.extend([ + Instruction::LocalGet { + local: self.alloc_local.expect("allocation pointer local must be present"), + type_: ValueType::I32, + }, + Instruction::CallName { + name: "__allocation_fail".into(), + type_: FunctionType::new([ValueType::I32, ValueType::I32], [ValueType::I32]), + }, + Instruction::Drop(ValueType::I32), + ]); + body + } + fn allocate_dynamic(&mut self, out: &mut Vec) -> StructuredResult<()> { let ptr = self.required_local(self.alloc_local, "allocation pointer")?; let end = self.required_local(self.alloc_end_local, "allocation end")?; @@ -1112,7 +1142,8 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32GtU); out.push(Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self + .allocation_failure_body(vec![Instruction::LocalGet { local: pages, type_: ValueType::I32 }]), else_body: Vec::new(), }); out.push(Instruction::LocalGet { local: ptr, type_: ValueType::I32 }); @@ -1124,7 +1155,8 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32GtU); out.push(Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self + .allocation_failure_body(vec![Instruction::LocalGet { local: pages, type_: ValueType::I32 }]), else_body: Vec::new(), }); out.push(Instruction::LocalGet { local: end, type_: ValueType::I32 }); @@ -1133,6 +1165,11 @@ impl<'a> StructuredEmitter<'a> { out.push(Instruction::I32Const(-(self.config.layout.alignment as i32))); out.push(Instruction::I32And); out.push(Instruction::LocalSet { local: end, type_: ValueType::I32 }); + self.check_heap_limit( + end, + vec![Instruction::LocalGet { local: pages, type_: ValueType::I32 }], + out, + ); out.push(Instruction::LocalGet { local: end, type_: ValueType::I32 }); out.push(Instruction::MemorySize(memory)); out.push(Instruction::I32Const(65536)); @@ -1150,13 +1187,13 @@ impl<'a> StructuredEmitter<'a> { Instruction::I32Add, Instruction::I32Const(16), Instruction::I32ShrU, - Instruction::LocalTee { local: pages, type_: ValueType::I32 }, Instruction::MemoryGrow(memory), Instruction::I32Const(-1), Instruction::I32Eq, Instruction::If { type_: BlockType::empty(), - then_body: vec![Instruction::Unreachable], + then_body: self + .allocation_failure_body(vec![Instruction::LocalGet { local: pages, type_: ValueType::I32 }]), else_body: Vec::new(), }, ], @@ -2813,7 +2850,7 @@ impl<'a> StructuredEmitter<'a> { } let memory = self .module - .push_memory(Memory { minimum_pages: 1, maximum_pages: None }); + .push_memory(Memory { minimum_pages: 1, maximum_pages: Some(self.config.memory_max_pages) }); self.memory = Some(memory); memory } diff --git a/crates/core/src/wasm/fragments/allocation.wat.rs b/crates/core/src/wasm/fragments/allocation.wat.rs index 5c91dad..0b2be58 100644 --- a/crates/core/src/wasm/fragments/allocation.wat.rs +++ b/crates/core/src/wasm/fragments/allocation.wat.rs @@ -69,6 +69,15 @@ pub const ALLOC_HELPER: &str = r#" i32.and local.set $end local.get $end + i32.const {heap_limit} + i32.gt_u + if + local.get $size + local.get $ptr + call $__allocation_fail + return + end + local.get $end memory.size i32.const 65536 i32.mul diff --git a/crates/core/src/wasm/tests.rs b/crates/core/src/wasm/tests.rs index 9a1ad42..c2c9695 100644 --- a/crates/core/src/wasm/tests.rs +++ b/crates/core/src/wasm/tests.rs @@ -141,7 +141,7 @@ fn emits_wat_for_public_scalar_function() { fn emits_wat_with_runtime_for_string_function() { let wasm = compile_wasm("pub fn greeting() { \"hello\" }"); - assert!(wasm.wat.contains("(memory 1)")); + assert!(wasm.wat.contains("(memory 1 256)")); assert!(!wasm.wat.contains("(func $__alloc")); assert!(!wasm.wat.contains("(export \"__regulus_string_len\")")); assert!(!wasm.wat.contains("(export \"__regulus_value_tag\")")); @@ -185,7 +185,8 @@ fn omits_unreachable_runtime_fragment_domains() { let wasm = compile_wasm("pub fn join() { \"a\" <> \"b\" }"); assert!(!wasm.wat.contains("(func $__string_concat"), "{}", wasm.wat); - assert!(!wasm.wat.contains("(func $__alloc"), "{}", wasm.wat); + assert!(!wasm.wat.contains("(func $__alloc "), "{}", wasm.wat); + assert!(wasm.wat.contains("(func $__allocation_fail"), "{}", wasm.wat); assert!(!wasm.wat.contains("(func $__dict_new"), "{}", wasm.wat); assert!(!wasm.wat.contains("(func $__list_cons"), "{}", wasm.wat); assert!(!wasm.wat.contains("(func $__bit_array_new"), "{}", wasm.wat); @@ -209,7 +210,7 @@ fn renders_deterministic_structured_wat_for_managed_values() { insta::assert_snapshot!(wasm.wat, @r#" (module (type (func (result i32))) - (memory 1) + (memory 1 256) (func $pair (type 0) (result i32) i32.const 1024 ) @@ -435,7 +436,7 @@ case list.map([1], fn(x) { x + 1 }) { "#, ); - assert!(wasm.wat.contains("(global (mut i32)"), "{}", wasm.wat); + assert!(wasm.wat.contains("(global $__heap (mut i32)"), "{}", wasm.wat); assert!(!wasm.wat.contains("$__list_cons"), "{}", wasm.wat); assert!(!wasm.wat.contains("$__closure_new"), "{}", wasm.wat); @@ -2617,6 +2618,94 @@ fn runtime_allocation_grows_memory_without_moving_existing_objects() { assert_eq!(&bytes[8..10], b"ab"); } +#[test] +fn runtime_allocation_handles_page_boundaries() { + let fill_first_page = runtime::WASM_PAGE_SIZE - runtime::RuntimeConfig::DEFAULT.heap_start; + let wat = format!( + r#" +(func $fill_first_page (export "fill_first_page") (result i32) + i32.const {fill_first_page} + call $__alloc) +(func $cross_into_next_page (export "cross_into_next_page") (result i32) + i32.const 1 + call $__alloc) +(func $pages (export "pages") (result i32) + memory.size) +"#, + ); + let instance = runtime_helper_instance(&wat); + let (engine, mut store, instance) = instance; + let _engine = engine; + let pages = instance + .get_typed_func::<(), i32>(&mut store, "pages") + .expect("get pages export"); + assert_eq!(pages.call(&mut store, ()).expect("initial pages"), 1); + + let fill_first_page = instance + .get_typed_func::<(), i32>(&mut store, "fill_first_page") + .expect("get fill_first_page export"); + assert_eq!( + fill_first_page.call(&mut store, ()).expect("fill first page"), + runtime::RuntimeConfig::DEFAULT.heap_start as i32 + ); + assert_eq!(pages.call(&mut store, ()).expect("pages after fill"), 1); + + let cross_into_next_page = instance + .get_typed_func::<(), i32>(&mut store, "cross_into_next_page") + .expect("get cross_into_next_page export"); + assert_eq!( + cross_into_next_page.call(&mut store, ()).expect("cross page"), + runtime::WASM_PAGE_SIZE as i32 + ); + assert_eq!(pages.call(&mut store, ()).expect("pages after cross"), 2); +} + +#[test] +fn runtime_allocation_allows_exact_heap_limit_boundary() { + let fill_heap_limit = + runtime::RuntimeConfig::DEFAULT.memory_limit_bytes() - runtime::RuntimeConfig::DEFAULT.heap_start; + let wat = format!( + r#" +(func $fill_heap_limit (export "fill_heap_limit") (result i32) + i32.const {fill_heap_limit} + call $__alloc) +(func $past_heap_limit (export "past_heap_limit") (result i32) + i32.const 1 + call $__alloc) +(func $pages (export "pages") (result i32) + memory.size) +"#, + ); + let instance = runtime_helper_instance(&wat); + let (engine, mut store, instance) = instance; + let _engine = engine; + let fill_heap_limit = instance + .get_typed_func::<(), i32>(&mut store, "fill_heap_limit") + .expect("get fill_heap_limit export"); + assert_eq!( + fill_heap_limit.call(&mut store, ()).expect("fill heap limit"), + runtime::RuntimeConfig::DEFAULT.heap_start as i32 + ); + let pages = instance + .get_typed_func::<(), i32>(&mut store, "pages") + .expect("get pages export"); + assert_eq!( + pages.call(&mut store, ()).expect("pages after fill"), + runtime::RuntimeConfig::DEFAULT.memory_max_pages as i32 + ); + + let past_heap_limit = instance + .get_typed_func::<(), i32>(&mut store, "past_heap_limit") + .expect("get past_heap_limit export"); + assert!(past_heap_limit.call(&mut store, ()).is_err()); + assert_allocation_panic_payload( + &instance, + &mut store, + 1, + runtime::RuntimeConfig::DEFAULT.memory_limit_bytes() as u64, + ); +} + #[test] fn runtime_allocation_failure_writes_structured_panic_payload() { let instance = runtime_helper_instance_with_memory( @@ -2652,6 +2741,53 @@ fn runtime_allocation_failure_writes_structured_panic_payload() { assert_eq!(u64::from_le_bytes(bytes[20..28].try_into().unwrap()), 4096); } +#[test] +fn runtime_allocation_fails_before_configured_heap_limit() { + let instance = runtime_helper_instance( + r#" +(func $past_limit (export "past_limit") (result i32) + i32.const 16773121 + call $__alloc) +"#, + ); + let (engine, mut store, instance) = instance; + let _engine = engine; + let past_limit = instance + .get_typed_func::<(), i32>(&mut store, "past_limit") + .expect("get past_limit export"); + assert!(past_limit.call(&mut store, ()).is_err()); + + assert_allocation_panic_payload(&instance, &mut store, 16_773_121, 4096); +} + +#[test] +fn structured_codegen_allocation_failure_writes_structured_panic_payload() { + let span = Span::new(SourceFileId(0), 1, 10); + let allocate = exported_function_with_body( + "allocate_too_much", + &Type::String, + ir::Expression { + type_: Type::String, + span, + kind: ExpressionKind::Memory(ir::MemoryOperation::Allocate { bytes: Box::new(int_expr("16773121", span)) }), + }, + span, + ); + let wasm = ir_module(vec![allocate], span) + .emit_wasm() + .expect("emit allocation failure wasm"); + let engine = Engine::default(); + let module = Module::new(&engine, &wasm.bytes).expect("compile wasm module"); + let mut store = Store::new(&engine, ()); + let instance = Instance::new(&mut store, &module, &[]).expect("instantiate module"); + let allocate_too_much = instance + .get_typed_func::<(), i32>(&mut store, "allocate_too_much") + .expect("get allocate_too_much export"); + assert!(allocate_too_much.call(&mut store, ()).is_err()); + + assert_allocation_panic_payload(&instance, &mut store, 16_773_121, 4096); +} + #[test] fn runtime_allocation_traps_before_size_overflow() { let instance = runtime_helper_instance( diff --git a/docs/internal/tasks/20_runtime_memory_hardening.md b/docs/internal/tasks/20_runtime_memory_hardening.md index 5952933..13525de 100644 --- a/docs/internal/tasks/20_runtime_memory_hardening.md +++ b/docs/internal/tasks/20_runtime_memory_hardening.md @@ -10,23 +10,16 @@ longer-running tests. ### Allocation behavior - [x] Add overflow checks for allocation size and alignment arithmetic. -- [ ] Define whether dynamic memory may grow at runtime. -- [ ] Add heap-limit checks before bump allocation succeeds. -- [ ] Add deterministic failure behavior for allocation failure. -- [ ] Test allocation at page boundaries and near overflow limits. +- [x] Define whether dynamic memory may grow at runtime. +- [x] Add heap-limit checks before bump allocation succeeds. +- [x] Add deterministic failure behavior for allocation failure. +- [x] Test allocation at page boundaries and near overflow limits. -allow dynamic memory growth by default for the current bump +We should allow dynamic memory growth by default for the current bump allocator phase. Growth keeps non-trivial examples usable before reclamation -exists, but it must become bounded by an explicit maximum and fail -deterministically. A fixed-memory policy can still be added later as a target -or host profile for constrained environments. - -Website reference note: `memory.grow` is standard Wasm behavior. It returns the -old page count or `-1` on failure, and Wasm pages are currently 64KiB. Browser -`WebAssembly.Memory.grow()` is widely available, but growing detaches existing -JavaScript `ArrayBuffer` views, so host adapters must reacquire memory views -after calls. Wasmtime also supports growth, but host memory can relocate and -growth can fail because of maximum limits, resource limiters, or OOM. +exists, but it is bounded by an explicit maximum and fails deterministically. +A fixed-memory policy can still be added later as a target or host profile for +constrained environments. ### Runtime object validation diff --git a/docs/website/reference/runtime-memory.md b/docs/website/reference/runtime-memory.md index a56d216..bec76ee 100644 --- a/docs/website/reference/runtime-memory.md +++ b/docs/website/reference/runtime-memory.md @@ -15,10 +15,11 @@ relocate and growth can fail because of maximum limits, resource limiters, or OOM.[^wasmtime-grow] Regulus allows dynamic memory growth by default during the current bump -allocator phase. This keeps examples and longer-running tests usable before -freeing, arena reset, reference counting, or garbage collection exist. Future -targets may choose a fixed-memory policy, but dynamic growth remains the -default capacity strategy for the general runtime. +allocator phase, bounded by an explicit maximum page count. This keeps examples +and longer-running tests usable before freeing, arena reset, reference counting, +or garbage collection exist. Future targets may choose a fixed-memory policy, +but dynamic growth remains the default capacity strategy for the general +runtime. ## Host pointers