diff --git a/crates/core/src/runtime.rs b/crates/core/src/runtime.rs index da1d920..16dd335 100644 --- a/crates/core/src/runtime.rs +++ b/crates/core/src/runtime.rs @@ -87,6 +87,7 @@ impl RuntimeConfig { 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_start}", &self.heap_start.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 diff --git a/crates/core/src/wasm/fragments/allocation.wat.rs b/crates/core/src/wasm/fragments/allocation.wat.rs index 0b2be58..b2cd875 100644 --- a/crates/core/src/wasm/fragments/allocation.wat.rs +++ b/crates/core/src/wasm/fragments/allocation.wat.rs @@ -4,6 +4,31 @@ pub const ALLOC_HELPER: &str = r#" (func $__last_panic (export "__last_panic") (result i32) global.get $__last_panic_payload ) + (func $__arena_mark (result i32) + global.get $__heap + ) + (func $__arena_reset (param $mark i32) + local.get $mark + i32.const {heap_start} + i32.lt_u + if + unreachable + end + local.get $mark + global.get $__heap + i32.gt_u + if + unreachable + end + local.get $mark + i32.const {alignment_mask} + i32.and + if + unreachable + end + local.get $mark + global.set $__heap + ) (func $__allocation_fail (param $size i32) (param $heap i32) (result i32) i32.const {allocation_failure_offset} i32.const 10 diff --git a/crates/core/src/wasm/tests.rs b/crates/core/src/wasm/tests.rs index 6453516..0c57309 100644 --- a/crates/core/src/wasm/tests.rs +++ b/crates/core/src/wasm/tests.rs @@ -2690,6 +2690,107 @@ fn runtime_allocation_grows_memory_without_moving_existing_objects() { assert_eq!(&bytes[8..10], b"ab"); } +#[test] +fn runtime_arena_reset_reuses_dynamic_allocations_after_mark() { + let instance = runtime_helper_instance( + r#" +(data (i32.const 2048) "keepaabb") +(global $keep (mut i32) (i32.const 0)) +(global $temp1 (mut i32) (i32.const 0)) +(global $temp2 (mut i32) (i32.const 0)) +(func $exercise (export "exercise") + (local $mark i32) + i32.const 2048 + i32.const 4 + call $__string_new + global.set $keep + call $__arena_mark + local.set $mark + i32.const 2052 + i32.const 2 + call $__string_new + global.set $temp1 + local.get $mark + call $__arena_reset + i32.const 2054 + i32.const 2 + call $__string_new + global.set $temp2) +(func $keep (export "keep") (result i32) + global.get $keep) +(func $temp1 (export "temp1") (result i32) + global.get $temp1) +(func $temp2 (export "temp2") (result i32) + global.get $temp2) +"#, + ); + let (engine, mut store, instance) = instance; + let _engine = engine; + let exercise = instance + .get_typed_func::<(), ()>(&mut store, "exercise") + .expect("get exercise export"); + exercise.call(&mut store, ()).expect("exercise arena reset"); + + let keep = instance + .get_typed_func::<(), i32>(&mut store, "keep") + .expect("get keep export"); + let temp1 = instance + .get_typed_func::<(), i32>(&mut store, "temp1") + .expect("get temp1 export"); + let temp2 = instance + .get_typed_func::<(), i32>(&mut store, "temp2") + .expect("get temp2 export"); + let keep = keep.call(&mut store, ()).expect("read keep pointer") as usize; + let temp1 = temp1.call(&mut store, ()).expect("read first temp pointer") as usize; + let temp2 = temp2.call(&mut store, ()).expect("read second temp pointer") as usize; + + assert_eq!(keep, runtime::RuntimeConfig::DEFAULT.heap_start as usize); + assert_eq!(temp1, temp2); + assert!(temp1 > keep); + + let memory = instance.get_memory(&mut store, "memory").expect("memory export"); + let mut static_bytes = [0; 8]; + memory.read(&store, 2048, &mut static_bytes).expect("read static data"); + assert_eq!(&static_bytes, b"keepaabb"); + + let mut keep_bytes = [0; 16]; + memory.read(&store, keep, &mut keep_bytes).expect("read keep object"); + assert_eq!(u32::from_le_bytes(keep_bytes[0..4].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(keep_bytes[4..8].try_into().unwrap()), 4); + assert_eq!(&keep_bytes[8..12], b"keep"); + + let mut temp_bytes = [0; 16]; + memory.read(&store, temp2, &mut temp_bytes).expect("read reset object"); + assert_eq!(u32::from_le_bytes(temp_bytes[0..4].try_into().unwrap()), 1); + assert_eq!(u32::from_le_bytes(temp_bytes[4..8].try_into().unwrap()), 2); + assert_eq!(&temp_bytes[8..10], b"bb"); +} + +#[test] +fn runtime_arena_reset_rejects_invalid_marks() { + let instance = runtime_helper_instance( + r#" +(func $before_heap_start (export "before_heap_start") + i32.const 2048 + call $__arena_reset) +(func $unaligned (export "unaligned") + i32.const 4097 + call $__arena_reset) +(func $past_heap (export "past_heap") + i32.const 8192 + call $__arena_reset) +"#, + ); + let (engine, mut store, instance) = instance; + let _engine = engine; + for name in ["before_heap_start", "unaligned", "past_heap"] { + let reset = instance + .get_typed_func::<(), ()>(&mut store, name) + .unwrap_or_else(|_| panic!("get {name} export")); + assert!(reset.call(&mut store, ()).is_err(), "{name} should trap"); + } +} + #[test] fn runtime_allocation_handles_page_boundaries() { let fill_first_page = runtime::WASM_PAGE_SIZE - runtime::RuntimeConfig::DEFAULT.heap_start; diff --git a/docs/internal/README.md b/docs/internal/README.md index 5cc379a..5d36cab 100644 --- a/docs/internal/README.md +++ b/docs/internal/README.md @@ -13,24 +13,21 @@ Wasm artifact. Current work is focused on making that Gleam-project-to-Wasm path more usable: -1. Harden runtime memory and host pointer behavior. -2. Improve CLI artifacts, diagnostics, and host metadata. -3. Fill stdlib and dependency gaps that block realistic projects. -4. Add larger examples as acceptance fixtures after the core gaps are planned. +1. Improve CLI artifacts, diagnostics, and host metadata. +2. Fill stdlib and dependency gaps that block realistic projects. +3. Add larger examples as acceptance fixtures after the core gaps are planned. ## Specs - [Stdlib and host interop](specs/16_stdlib_and_host_interop.md) - [CLI and build outputs](specs/17_cli_and_build_outputs.md) - [Example projects](specs/18_example_projects.md) -- [Runtime memory hardening](specs/19_runtime_memory_hardening.md) - [WASI host ABI](specs/20_wasi_host_abi.md) ## Task Trackers - [Stdlib and host interop](tasks/16_stdlib_and_host_interop.md) - [CLI and build outputs](tasks/17_cli_and_build_outputs.md) -- [Runtime memory hardening](tasks/20_runtime_memory_hardening.md) - [WASI host ABI](tasks/21_wasi_host_abi.md) ### Examples diff --git a/docs/internal/specs/18_example_projects.md b/docs/internal/specs/18_example_projects.md index db943d3..5581bf7 100644 --- a/docs/internal/specs/18_example_projects.md +++ b/docs/internal/specs/18_example_projects.md @@ -38,7 +38,7 @@ adapter-owned opaque handle tables. Structured JavaScript values passed into Gleam imports or exported function parameters are still deferred. Broader dependency source compilation, full -dynamic decoding, and runtime memory hardening remain the main gaps for larger +dynamic decoding, and arena reset reclamation remain the main gaps for larger examples. `gleam/dynamic`, `gleam/dynamic/decode`, `gleam/uri`, `gleam/pair`, diff --git a/docs/internal/specs/19_runtime_memory_hardening.md b/docs/internal/specs/19_runtime_memory_hardening.md deleted file mode 100644 index b74b56f..0000000 --- a/docs/internal/specs/19_runtime_memory_hardening.md +++ /dev/null @@ -1,104 +0,0 @@ -# Runtime memory hardening - -The current runtime uses static data plus bump allocation in guest linear -memory. This is enough for tests and small examples, but it does not yet define -failure behavior, reclamation, or long-running host interaction. - -## Scope - -This spec covers the next runtime-memory milestone. It does not require garbage -collection before examples can run, but it should make allocation behavior and -host ownership explicit. - -## Current model - -- Static managed objects are emitted as data segments. -- Dynamic allocation starts after static data. -- `__alloc` aligns requests to 8 bytes. -- Allocation advances the heap pointer. -- Objects are non-moving. -- The runtime does not free individual objects. - -## Required hardening - -The runtime should define and test: - -- allocation size overflow checks -- heap-limit checks -- `memory.grow` behavior where growth is allowed -- deterministic allocation failure paths -- object header validation for host readers -- clear ownership rules for host-provided managed pointers -- diagnostics or traps for invalid runtime helper use - -## Deferred memory strategies - -Freeing, garbage collection, reference counting, arena resets, and moving -compaction are deferred. Before any of them are implemented, the host ABI must -say whether pointers can survive calls, instance resets, or arena resets. - -## Host interaction - -Hosts may inspect guest-managed values through exported helpers. Hosts must not -mutate runtime object memory. Future APIs that accept host-provided handles or -managed pointers must document ownership and lifetime rules. - -## Runtime helper inventory - -Allocation helpers: - -- `__alloc`, `__allocation_fail`, and `__last_panic` - -Managed value helpers: - -- tuple, record, custom, closure, opaque, option, order, error, and panic - constructors -- raw field readers used by generated code - -Closure helpers: - -- closure allocation and indirect-call capture layout helpers - -Equality and ordering helpers: - -- structural equality and comparison for strings, bit arrays, lists, tuples, - records, custom values, and scalar slots - -Debug helpers: - -- debug tags, panic/error reasons, and payload readers - -Dynamic value helpers: - -- dynamic value constructors, classifiers, field readers, decoder - constructors, and decoder runners - -Host adapter helpers: - -- JS adapter exports for allocation, string creation/reading, managed value - tags, arity, constructors, fields, and opaque handle readers - -## Host reader validation - -Exported JS adapter reader helpers validate object headers before reading: - -- `__regulus_value_tag(0)` returns `0` as the nil-list/null sentinel. -- Non-zero reader pointers must reference a known runtime object tag. -- String readers require tag `1` and validate the byte range. -- Handle readers require tag `8` and validate the opaque payload range. -- `__regulus_value_arity` validates the object range. It returns field counts - for field objects and `0` for strings and bit arrays. -- `__regulus_value_constructor` validates the object range. It returns the - constructor or reason tag for custom, error, and panic objects, and `0` for - other valid objects. -- `__regulus_value_field` only accepts list cons, tuple, record, custom, error, - and panic objects. It traps when the field index is out of range. - -Malformed non-zero pointers, unknown tags, wrong reader/object pairs, oversized -payloads, and out-of-range field indexes trap. The only sentinel returns in the -reader surface are nil tag `0`, non-constructor value constructor `0`, and -non-field byte-object arity `0`. - -## Active tasks - -See [Runtime memory hardening tasks](../tasks/20_runtime_memory_hardening.md). diff --git a/docs/internal/tasks/20_runtime_memory_hardening.md b/docs/internal/tasks/20_runtime_memory_hardening.md deleted file mode 100644 index d686d6f..0000000 --- a/docs/internal/tasks/20_runtime_memory_hardening.md +++ /dev/null @@ -1,51 +0,0 @@ -# Runtime memory hardening tasks - -## Goal - -Make runtime allocation and host memory inspection safe enough for examples and -longer-running tests. - -## Tasks - -### Allocation behavior - -- [x] Add overflow checks for allocation size and alignment arithmetic. -- [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. - -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 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 - -- [x] Add a runtime helper inventory grouped by allocation, managed values, - closures, equality, debug, dynamic values, and host adapters. -- [x] Validate object tags, sizes, arity, and field indexes in exported reader - helpers. -- [x] Add tests for invalid host reader calls. -- [x] Document which helper failures trap and which return sentinel values. - -### Ownership and lifetimes - -- [ ] Document when host-held managed pointers remain valid. -- [ ] Document rules for host-provided managed pointers. -- [ ] Define how opaque host handles interact with runtime ownership. -- [ ] Reject unsupported pointer or handle ownership shapes during ABI - validation. - -### Deferred reclamation - -- [ ] Record the first supported reclamation strategy: none, arena reset, - reference counting, or garbage collection. -- [ ] Add tests that long-running examples fail clearly before exhausting - memory, or grow memory when growth is enabled. - -## Done when - -Allocation failures, host reader misuse, and pointer lifetime assumptions are -specified, tested, and visible in diagnostics or documented traps. diff --git a/docs/website/development/runtime-memory.md b/docs/website/development/runtime-memory.md index d508fe9..c085702 100644 --- a/docs/website/development/runtime-memory.md +++ b/docs/website/development/runtime-memory.md @@ -12,12 +12,28 @@ Regulus uses a resettable bump arena in linear memory. - `$__heap` is the next dynamic allocation offset. - `__alloc` aligns each request to 8 bytes. - Allocation advances the heap pointer and never frees individual objects. -- Objects are non-moving until the Wasm instance is reset or a future explicit - arena reset runs. +- Objects are non-moving until the Wasm instance is reset or explicit arena + reset runs. This keeps allocation small and makes raw `i32` managed-value pointers stable for generated code and Wasmtime tests. +## Helper inventory + +Runtime helpers are grouped by use: + +- allocation: `__alloc`, `__allocation_fail`, `__last_panic`, + `__arena_mark`, and `__arena_reset` +- managed values: tuple, record, custom, closure, opaque, option, order, error, + and panic constructors, plus raw field readers +- closures: allocation and indirect-call capture layout helpers +- equality and ordering: structural equality and comparison for runtime values +- debug: debug tags, panic/error reasons, and payload readers +- dynamic values: constructors, classifiers, field readers, decoder + constructors, and decoder runners +- host adapters: JS exports for allocation, strings, managed value tags, + arity, constructors, fields, and opaque handle readers + ## Growth and failure Every allocator path must call `__alloc` or a helper that delegates to it. @@ -35,6 +51,17 @@ Allocation failure records a tag-10 panic payload through `__last_panic`. The payload reason tag is `1`; slot 0 is the requested allocation size and slot 1 is the heap pointer before allocation. +Runtime allocation tests cover: + +- growth without moving existing managed objects +- page-boundary allocations +- exact heap-limit allocations +- deterministic failure before the configured heap limit +- deterministic failure when `memory.grow` cannot grow +- structured panic payloads for allocation failure +- arena reset reuse of dynamic allocations after a mark +- rejection of invalid arena reset marks + ## Host ownership Managed pointers exported to a host are borrowed. The guest runtime owns the @@ -44,11 +71,71 @@ the host must not retain it across Wasm instance reset or a future arena reset. Browser adapters must refresh typed array views after memory growth. Wasmtime tests can read the exported memory directly after each call. -## Deferred work +Host-provided managed pointers must come from the same guest instance or from +that instance's exported adapter helpers. Hosts must not synthesize pointers, +reuse pointers across instances or resets, or pass pointers to directly mutated +runtime memory. + +Opaque host handles split ownership. The guest runtime owns only the opaque +wrapper object containing a type tag and adapter handle id. The adapter owns the +host value behind that id. Clearing or replacing the adapter handle table +invalidates handle ids even if old wrapper pointers still exist. + +JS host ABI validation rejects ownership-ambiguous managed imports. Imports may +receive scalars, strings, or opaque handles. Structured managed values may be +returned from exports through reader helpers, but they are not accepted as JS +host import parameters until writer and ownership rules are explicit. + +## Host reader validation + +Exported JS adapter reader helpers validate object headers before reading. + +These failures trap: + +- non-zero pointers outside memory +- unknown runtime object tags +- object payloads whose declared size extends past memory +- string readers called for non-string objects +- handle readers called for non-opaque objects +- field readers called for strings, bit arrays, closures, or opaque objects +- field indexes greater than or equal to the object arity + +Only these reader results are sentinel values: + +- `__regulus_value_tag(0)` returns `0` for nil-list/null. +- `__regulus_value_constructor(ptr)` returns `0` for valid non-constructor + objects. +- `__regulus_value_arity(ptr)` returns `0` for valid strings and bit arrays. + +All other malformed helper calls are caller bugs. + +## Arena reset reclamation + +Arena reset is the selected reclamation strategy. It is the smallest step +beyond the bump allocator: save a heap mark before a bounded scope, then reset +`$__heap` to that mark when the scope ends. + +`__arena_mark() -> i32` returns the current heap pointer. `__arena_reset(mark)` +sets `$__heap` back to a previous mark. Reset traps if the mark is before the +dynamic heap start, after the current heap, or not 8-byte aligned. + +Reset invalidates every dynamic object allocated after the mark. Static data and +dynamic objects allocated before the mark remain valid. Later allocations may +reuse reset-owned memory. + +Automatic call/request reset boundaries are not yet generated. Host adapters +and compiler-generated code must not return or retain pointers allocated after +a mark that will be reset. + +Reference counting is not the selected strategy for this milestone. It would +require generated retain/release operations for every managed assignment, field +store, capture, return, and host boundary. It would also need handle-table +integration and a cycle policy. -Reference counting and tracing garbage collection are deferred. Either design -needs complete root metadata, stack and local tracking, clear host ownership -rules, and tests for pointer lifetime across host calls. +Tracing garbage collection is also not selected. Wasm does not expose the +operand stack or locals to the runtime, so Regulus would need an explicit +shadow stack, stack maps, or generated root-registration code before a tracing +collector can find live managed values. See also: diff --git a/docs/website/reference/runtime-memory.md b/docs/website/reference/runtime-memory.md index 7d17fe0..6b2e14e 100644 --- a/docs/website/reference/runtime-memory.md +++ b/docs/website/reference/runtime-memory.md @@ -27,10 +27,40 @@ Managed pointers are guest-memory offsets. Hosts may inspect values through documented adapters or exported helpers, but must not mutate runtime object memory. +Host-held managed pointers remain valid while all of these are true: + +- the WebAssembly instance is alive +- the pointer came from the same instance +- the value has not been invalidated by arena reset +- the host treats the pointer as borrowed and read-only + +Current runtime objects are non-moving, so memory growth does not change the +numeric pointer value. It can still invalidate host-side memory views. Hosts +that need to keep a value should keep the numeric pointer and reacquire memory +views before reading. + Hosts must not cache JavaScript typed-array views across calls that may allocate or grow memory. Wasmtime hosts must also avoid retaining raw host pointers or unsafe slices across growth, because the underlying memory can relocate. +Host-provided managed pointers are borrowed pointers into the same guest +memory. A host must only pass a managed pointer that was returned by the same +instance or created through that instance's exported adapter helpers. Hosts must +not synthesize pointers, pass pointers from another instance, pass pointers +after instance reset, or pass pointers to memory they mutated directly. + +Opaque host handles are runtime objects that contain a type tag and an adapter +handle-table id. The adapter owns the JavaScript value behind the id. The guest +runtime only owns the small opaque wrapper object in linear memory; it does not +own, free, or inspect the JavaScript value. Clearing or replacing the adapter +handle table invalidates handle ids even if an old opaque wrapper pointer still +exists. + +JavaScript host ABI validation rejects ownership-ambiguous managed imports. +Imports may receive scalars, strings, or opaque handles. Structured managed +values are supported as exported return values through reader helpers, but not +as JS host import parameters until writer and ownership rules are explicit. + ## Host reader failures JavaScript host targets export low-level reader helpers for strings, managed