Stream: git-wasmtime

Topic: wasmtime / issue #13112 Should accesses to the GC heap us...


view this post on Zulip Wasmtime GitHub notifications bot (Apr 15 2026 at 21:32):

fitzgen opened issue #13112:

Random thought while reading this, shouldn't notrap and aligned be absent here? Given the sandboxing strategy I'd expect this to not be asserted to be aligned and additionally would be allowed to trap

_Originally posted by @alexcrichton in https://github.com/bytecodealliance/wasmtime/pull/13107#discussion_r3088485306_

view this post on Zulip Wasmtime GitHub notifications bot (Apr 15 2026 at 21:32):

fitzgen added the wasm-proposal:gc label to Issue #13112.

view this post on Zulip Wasmtime GitHub notifications bot (Apr 15 2026 at 21:52):

fitzgen commented on issue #13112:

What I'm worried about is UB-of-sorts where we're telling Craneilft that this load is always aligned and never traps and then at runtime, assuming there's a bug in either Cranelift or Wasmtime's GC, that's violated (in theory causing UB). I'm wary to bucket this under having a known set of possible outcomes because we're effectively violating a core assumption and I'm not sure we can enumerate all the outcomes. By analogy, the vec OOB isn't UB to hit the #[cold] block naturally, but here I'm worried that it would be UB somehow to hit a trap here.

I could see this argument for aligned.

I don't think it applies to notrap though.

view this post on Zulip Wasmtime GitHub notifications bot (Apr 15 2026 at 22:08):

cfallin commented on issue #13112:

I guess there's a question of what we intend notrap and aligned to mean. We document them here as:

So by the docs, treating Cranelift as opaque, I think we're actually safe to use aligned today (because at worst, we define that the load/store traps or returns a wrong result for a store; neither of those is UB or propagates beyond the intended sandbox). But we are not safe to use notrap today if we want to be robust to accidental sandbox violations because we simply say that the IR asserts that a trap will not occur; we don't say what happens if it does.

We could define notrap more precisely to mean "definitely will not have trap metadata, and may or may not cause a SIGSEGV, and if it does, may or may not occur at exactly the right point; if it does SEGV, it will do so at the given address". That permits all of our intended optimizations (code motion, dead load/store removal), and is still constrained enough that we can reason about that behavior interacting with the Wasmtime runtime: in particular, "may still SEGV, may not, but will not alter address" would let us be reasonably sure about holding the sandbox boundary.

All that said, my take: I think we should reason about GC accesses the same way we do about Wasm guest loads/stores. I realize that's giving up optimization opportunity, but it is closer to the original intent of the idea of our GC sandboxing: we should "just" be using linear memories under the hood.

Separately, though, we should probably think about making post-trap state unobservable as an option; and if we do that, we can then (still) do dead store/load removal, and store reordering (between other sequence points like opaque hostcalls), not only for the GC heap but also for user linear memories as well.

view this post on Zulip Wasmtime GitHub notifications bot (Apr 15 2026 at 22:08):

cfallin edited a comment on issue #13112:

I guess there's a question of what we intend notrap and aligned to mean. We document them here as:

So by the docs, treating Cranelift as opaque, I think we're actually safe to use aligned today (because at worst, we define that the load/store traps or returns a wrong result for a load; neither of those is UB or propagates beyond the intended sandbox). But we are not safe to use notrap today if we want to be robust to accidental sandbox violations because we simply say that the IR asserts that a trap will not occur; we don't say what happens if it does.

We could define notrap more precisely to mean "definitely will not have trap metadata, and may or may not cause a SIGSEGV, and if it does, may or may not occur at exactly the right point; if it does SEGV, it will do so at the given address". That permits all of our intended optimizations (code motion, dead load/store removal), and is still constrained enough that we can reason about that behavior interacting with the Wasmtime runtime: in particular, "may still SEGV, may not, but will not alter address" would let us be reasonably sure about holding the sandbox boundary.

All that said, my take: I think we should reason about GC accesses the same way we do about Wasm guest loads/stores. I realize that's giving up optimization opportunity, but it is closer to the original intent of the idea of our GC sandboxing: we should "just" be using linear memories under the hood.

Separately, though, we should probably think about making post-trap state unobservable as an option; and if we do that, we can then (still) do dead store/load removal, and store reordering (between other sequence points like opaque hostcalls), not only for the GC heap but also for user linear memories as well.

view this post on Zulip Wasmtime GitHub notifications bot (Apr 16 2026 at 00:52):

alexcrichton commented on issue #13112:

Personally I agree with @cfallin's conclusion of treating gc loads/stores the same as wasm loads/stores, I feel that fits our sandboxing model best. W.r.t the optimization concerns you have @fitzgen, my naive assumption is "that's what binaryen is for" or some sort of optimization pass. For example we expect LLVM-optimized-wasm to be suitable for "ok we can't move these memory opts", so I feel like we should have a similar expectation for GC-using wasm where it should be optimal coming in, not rely on Cranelift to clean up the wasm itself. To me Cranelift is responsible for primarily cleaning up Wasmtime's runtime abstractions, e.g. the base pointer of linear memory and hoisting that out, but not for cleaning up the input wasm.

view this post on Zulip Wasmtime GitHub notifications bot (Apr 16 2026 at 13:24):

tschneidereit commented on issue #13112:

For example we expect LLVM-optimized-wasm to be suitable for "ok we can't move these memory opts", so I feel like we should have a similar expectation for GC-using wasm where it should be optimal coming in

I'm not sure how well this will hold up for too much longer, fwiw, perhaps in particular for GC: there are lots of languages that already don't go through LLVM, and then there are some (including Rust!) that do go through LLVM, but not through wasm-opt, which LLVM itself pretty much assumes to be part of its optimization pipeline.

Long-term, my guess is that we'll have to add at least the most obvious optimizations that would happen in wasm-opt if used, and perhaps some of what's happening in LLVM itself, too.

view this post on Zulip Wasmtime GitHub notifications bot (May 05 2026 at 18:55):

alexcrichton commented on issue #13112:

I've tested out this diff:

<details>

diff --git a/crates/cranelift/src/func_environ/gc/enabled.rs b/crates/cranelift/src/func_environ/gc/enabled.rs
index 0ed775f7f3..53aff68499 100644
--- a/crates/cranelift/src/func_environ/gc/enabled.rs
+++ b/crates/cranelift/src/func_environ/gc/enabled.rs
@@ -27,6 +27,9 @@ mod null;
 #[cfg(feature = "gc-copying")]
 mod copying;

+const GC_MEMFLAGS: ir::MemFlags =
+    ir::MemFlags::new().with_trap_code(Some(crate::TRAP_INTERNAL_ASSERT));
+
 /// Get the default GC compiler.
 pub fn gc_compiler(func_env: &mut FuncEnvironment<'_>) -> WasmResult<Box<dyn GcCompiler>> {
     // If this function requires a GC compiler, that is not too bad of an
@@ -161,12 +164,9 @@ fn emit_gc_kind_assert(
             object_size: wasmtime_environ::VM_GC_HEADER_SIZE,
         },
     );
-    let kind_and_reserved_bits = builder.ins().load(
-        ir::types::I32,
-        ir::MemFlags::trusted().with_readonly(),
-        kind_addr,
-        0,
-    );
+    let kind_and_reserved_bits = builder
+        .ins()
+        .load(ir::types::I32, GC_MEMFLAGS, kind_addr, 0);
     let kind_mask = builder
         .ins()
         .iconst(ir::types::I32, i64::from(VMGcKind::MASK));
@@ -202,7 +202,7 @@ fn read_field_at_addr(
     );

     // Data inside GC objects is always little endian.
-    let flags = ir::MemFlags::trusted().with_endianness(ir::Endianness::Little);
+    let flags = GC_MEMFLAGS.with_endianness(ir::Endianness::Little);

     let value = match ty {
         WasmStorageType::I8 => builder.ins().load(ir::types::I8, flags, addr, 0),
@@ -321,7 +321,7 @@ fn write_field_at_addr(
     new_val: ir::Value,
 ) -> WasmResult<()> {
     // Data inside GC objects is always little endian.
-    let flags = ir::MemFlags::trusted().with_endianness(ir::Endianness::Little);
+    let flags = GC_MEMFLAGS.with_endianness(ir::Endianness::Little);

     match field_ty {
         WasmStorageType::I8 => {
@@ -928,12 +928,9 @@ pub fn translate_array_len(
             access_size: u8::try_from(ir::types::I32.bytes()).unwrap(),
         },
     );
-    let result = builder.ins().load(
-        ir::types::I32,
-        ir::MemFlags::trusted().with_readonly(),
-        len_field,
-        0,
-    );
+    let result = builder
+        .ins()
+        .load(ir::types::I32, GC_MEMFLAGS, len_field, 0);
     log::trace!("translate_array_len(..) -> {result:?}");
     Ok(result)
 }
@@ -1244,12 +1241,9 @@ pub fn translate_ref_test(
                 object_size: wasmtime_environ::VM_GC_HEADER_SIZE,
             },
         );
-        let actual_kind = builder.ins().load(
-            ir::types::I32,
-            ir::MemFlags::trusted().with_readonly(),
-            kind_addr,
-            0,
-        );
+        let actual_kind = builder
+            .ins()
+            .load(ir::types::I32, GC_MEMFLAGS, kind_addr, 0);
         let expected_kind = builder
             .ins()
             .iconst(ir::types::I32, i64::from(expected_kind.as_u32()));
@@ -1301,12 +1295,7 @@ pub fn translate_ref_test(
                     access_size: func_env.offsets.size_of_vmshared_type_index(),
                 },
             );
-            let actual_shared_ty = builder.ins().load(
-                ir::types::I32,
-                ir::MemFlags::trusted().with_readonly(),
-                ty_addr,
-                0,
-            );
+            let actual_shared_ty = builder.ins().load(ir::types::I32, GC_MEMFLAGS, ty_addr, 0);

             func_env.is_subtype(builder, actual_shared_ty, expected_shared_ty)
         }
@@ -1319,11 +1308,8 @@ pub fn translate_ref_test(
             let expected_shared_ty =
                 func_env.module_interned_to_shared_ty(&mut builder.cursor(), expected_interned_ty);

-            let actual_shared_ty = func_env.load_funcref_type_index(
-                &mut builder.cursor(),
-                ir::MemFlags::trusted().with_readonly(),
-                val,
-            );
+            let actual_shared_ty =
+                func_env.load_funcref_type_index(&mut builder.cursor(), GC_MEMFLAGS, val);

             func_env.is_subtype(builder, actual_shared_ty, expected_shared_ty)
         }
diff --git a/crates/cranelift/src/func_environ/gc/enabled/copying.rs b/crates/cranelift/src/func_environ/gc/enabled/copying.rs
index 1c82ddea03..b473df203d 100644
--- a/crates/cranelift/src/func_environ/gc/enabled/copying.rs
+++ b/crates/cranelift/src/func_environ/gc/enabled/copying.rs
@@ -33,7 +33,7 @@ impl CopyingCompiler {
         val: ir::Value,
     ) -> WasmResult<()> {
         // Data inside GC objects is always little endian.
-        let flags = ir::MemFlags::trusted().with_endianness(ir::Endianness::Little);
+        let flags = GC_MEMFLAGS.with_endianness(ir::Endianness::Little);

         match ty {
             WasmStorageType::Val(WasmValType::Ref(r)) => match r.heap_type.top() {
@@ -109,9 +109,7 @@ impl GcCompiler for CopyingCompiler {
         let object_addr = builder.ins().iadd(base, extended_array_ref);
         let len_addr = builder.ins().iadd_imm(object_addr, i64::from(len_offset));
         let len = init.len(&mut builder.cursor());
-        builder
-            .ins()
-            .store(ir::MemFlags::trusted(), len, len_addr, 0);
+        builder.ins().store(GC_MEMFLAGS, len, len_addr, 0);

         // Initialize elements.
         let len_to_elems_delta = builder.ins().iconst(ptr_ty, i64::from(len_to_elems_delta));
diff --git a/crates/cranelift/src/func_environ/gc/enabled/drc.rs b/crates/cranelift/src/func_environ/gc/enabled/drc.rs
index 66d9667c97..fb04425195 100644
--- a/crates/cranelift/src/func_environ/gc/enabled/drc.rs
+++ b/crates/cranelift/src/func_environ/gc/enabled/drc.rs
@@ -39,9 +39,7 @@ impl DrcCompiler {
                 access_size: u8::try_from(ir::types::I64.bytes()).unwrap(),
             },
         );
-        builder
-            .ins()
-            .load(ir::types::I64, ir::MemFlags::trusted(), pointer, 0)
+        builder.ins().load(ir::types::I64, GC_MEMFLAGS, pointer, 0)
     }

     /// Generate code to update the given GC reference's ref count to the new
@@ -64,9 +62,7 @@ impl DrcCompiler {
                 access_size: u8::try_from(ir::types::I64.bytes()).unwrap(),
             },
         );
-        builder
-            .ins()
-            .store(ir::MemFlags::trusted(), new_ref_count, pointer, 0);
+        builder.ins().store(GC_MEMFLAGS, new_ref_count, pointer, 0);
     }

     /// Generate code to increment or decrement the given GC reference's ref
@@ -108,9 +104,7 @@ impl DrcCompiler {

         // Load the current first list element, which will be our new next list
         // element.
-        let next = builder
-            .ins()
-            .load(ir::types::I32, ir::MemFlags::trusted(), head, 0);
+        let next = builder.ins().load(ir::types::I32, GC_MEMFLAGS, head, 0);

         // Update our object's header to point to `next` and consider itself part of the list.
         self.set_next_over_approximated_stack_root(func_env, builder, gc_ref, next);
@@ -120,9 +114,7 @@ impl DrcCompiler {
         self.mutate_ref_count(func_env, builder, gc_ref, 1);

         // Commit this object as the new head of the list.
-        builder
-            .ins()
-            .store(ir::MemFlags::trusted(), gc_ref, head, 0);
+        builder.ins().store(GC_MEMFLAGS, gc_ref, head, 0);
     }

     /// Load a pointer to the first element of the DRC heap's
@@ -137,7 +129,7 @@ impl DrcCompiler {
         let vmctx = builder.ins().global_value(ptr_ty, vmctx);
         builder.ins().load(
             ptr_ty,
-            ir::MemFlags::trusted().with_readonly(),
+            GC_MEMFLAGS,
             vmctx,
             i32::from(func_env.offsets.ptr.vmctx_gc_heap_data()),
         )
@@ -163,7 +155,7 @@ impl DrcCompiler {
                 access_size: u8::try_from(ir::types::I32.bytes()).unwrap(),
             },
         );
-        builder.ins().store(ir::MemFlags::trusted(), next, ptr, 0);
+        builder.ins().store(GC_MEMFLAGS, next, ptr, 0);
     }

     /// Set the in-over-approximated-stack-roots list bit in a `VMDrcHeader`'s
@@ -199,9 +191,7 @@ impl DrcCompiler {
                 access_size: u8::try_from(ir::types::I32.bytes()).unwrap(),
             },
         );
-        builder
-            .ins()
-            .store(ir::MemFlags::trusted(), new_reserved, ptr, 0);
+        builder.ins().store(GC_MEMFLAGS, new_reserved, ptr, 0);
     }

     /// Write to an uninitialized field or element inside a GC object.
@@ -214,7 +204,7 @@ impl DrcCompiler {
         val: ir::Value,
     ) -> WasmResult<()> {
         // Data inside GC objects is always little endian.
-        let flags = ir::MemFlags::trusted().with_endianness(ir::Endianness::Little);
+        let flags = GC_MEMFLAGS.with_endianness(ir::Endianness::Little);

         match ty {
             WasmStorageType::Val(WasmValType::Ref(r)) => match r.heap_type.top() {
@@ -396,9 +386,7 @@ impl GcCompiler for DrcCompiler {
         let object_addr = builder.ins().iadd(base, extended_array_ref);
         let len_addr = builder.ins().iadd_imm(object_addr, i64::from(len_offset));
         let len = init.len(&mut builder.cursor());
-        builder
-            .ins()
-            .store(ir::MemFlags::trusted(), len, len_addr, 0);
+        builder.ins().store(GC_MEMFLAGS, len, len_addr, 0);

         // Finally, initialize the elements.
         let len_to_elems_delta = builder.ins().iconst(ptr_ty, i64::from(len_to_elems_delta));
@@ -666,9 +654,7 @@ impl GcCompiler for DrcCompiler {
                 access_size: u8::try_from(ir::types::I32.bytes()).unwrap(),
             },
         );
-        let reserved = builder
-            .ins()
-            .load(ir::types::I32, ir::MemFlags::trusted(), ptr, 0);
+
[message truncated]

view this post on Zulip Wasmtime GitHub notifications bot (May 13 2026 at 15:13):

alexcrichton closed issue #13112:

Random thought while reading this, shouldn't notrap and aligned be absent here? Given the sandboxing strategy I'd expect this to not be asserted to be aligned and additionally would be allowed to trap

_Originally posted by @alexcrichton in https://github.com/bytecodealliance/wasmtime/pull/13107#discussion_r3088485306_


Last updated: Jun 01 2026 at 09:49 UTC