Summary
Function::from_vm_funcref (sys backend) unconditionally allocates a new StoreHandle and pushes it into the Store's function arena every time it's called, with no caching or deduplication. Since this function is on the hot path for converting any funcref-typed value crossing the host/guest boundary — not just one-time instantiation-time wrapping — a host embedding that repeatedly resolves the same underlying guest function (e.g. a callback passed as an argument, invoked many times) leaks one arena entry per resolution. In a long-running process this grows unboundedly and is never reclaimed.
Root cause
lib/api/src/backend/sys/entities/function/mod.rs, Function::from_vm_funcref:
pub(crate) unsafe fn from_vm_funcref(store: &mut impl AsStoreMut, funcref: VMFuncRef) -> Self {
let signature = {
let anyfunc = unsafe { funcref.0.as_ref() };
store.as_store_mut().engine().as_sys()
.lookup_signature(anyfunc.type_signature_hash)
.expect("Signature not found in store")
};
let vm_function = VMFunction {
anyfunc: MaybeInstanceOwned::Instance(funcref.0),
signature,
kind: wasmer_vm::VMFunctionKind::Static,
host_data: Box::new(()),
};
Self { handle: StoreHandle::new(store.objects_mut().as_sys_mut(), vm_function) }
}
StoreHandle::new → InternalStoreHandle::new (lib/vm/src/store.rs) always does list.push(val) into StoreObjects.functions: Vec<VMFunction>:
pub fn new(ctx: &mut StoreObjects, val: T) -> Self {
let list = T::list_mut(ctx);
let idx = NonZeroUsize::new(list.len() + 1).unwrap();
list.push(val); // unconditional append, every single call
Self { idx, marker: PhantomData }
}
StoreObjects has no removal/GC method for any of its nine object vectors (functions, tables, globals, instances, memories, extern_objs, exceptions, tags, function_environments) — reasonable for objects that are normally created once at module instantiation, but from_vm_funcref is not limited to that use case.
from_vm_funcref is reachable from every dynamic funcref crossing, not just instantiation:
Value::from_raw's Type::FuncRef arm (lib/api/src/entities/value.rs), hit whenever a funcref-typed value (parameter or return value) is marshalled from a raw ABI value.
NativeWasmTypeInto::from_abi/from_raw for Option<Function> (lib/api/src/utils/native/convert.rs), on the typed-function/native-ABI call path.
Table::get (lib/api/src/backend/sys/entities/table.rs) on any funcref-typed table element, via value_from_table_element.
Contrast with the sibling from_vm_extern (same file), used for the same purpose by Table/Global/Memory/Tag:
pub(crate) fn from_vm_extern(store: &mut impl AsStoreMut, vm_extern: VMExternFunction) -> Self {
Self { handle: unsafe { StoreHandle::from_internal(store.objects_mut().id(), vm_extern.unwrap_sys()) } }
}
This one just wraps an existing handle/index — zero allocation, no Vec::push. Function has no equivalent path because a VMFuncRef crosses the boundary as a bare pointer rather than an existing store index, so from_vm_funcref always fabricates a new entry instead of being able to look one up.
The underlying VMFuncRef's pointer (NonNull<VMCallerCheckedAnyfunc>) is stable per instance-local-function / per host Function for the life of the instance (traced through Instance::funcrefs / build_funcrefs in lib/vm/src/instance/mod.rs), and VMFuncRef already derives Hash/Eq/PartialEq by that pointer's address (lib/vm/src/lib.rs):
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct VMFuncRef(pub NonNull<VMCallerCheckedAnyfunc>);
So the same funcref reliably produces the same pointer value across repeated resolutions — nothing currently deduplicates against it.
Impact
Any embedding that repeatedly converts the same funcref-typed value into a wasmer::Function — e.g. a host holding a reference to a guest callback function and invoking it many times via Table::get + Function::call, or any code path that re-derives a Value::FuncRef from raw ABI values on each call — leaks one VMFunction entry (plus a cloned FunctionType from SignatureRegistry::lookup_signature, itself heap-allocated) per resolution, for the lifetime of the Store. In a long-running host process handling many such calls, this is unbounded memory growth with no way to reclaim it short of dropping the entire Store.
Suggested fix
Add a dedup cache to StoreObjects (or scoped to Function::from_vm_funcref itself) keyed by the VMFuncRef's pointer identity — e.g. HashMap<NonNull<VMCallerCheckedAnyfunc>, InternalStoreHandle<VMFunction>> (or keyed on VMFuncRef directly, since it already has the needed Hash/Eq impls) — checked before constructing a new VMFunction/pushing, mirroring how from_vm_extern already reuses an existing handle instead of allocating. No lock should be needed since StoreObjects is always accessed via &mut. This would also fix Table::get and the typed-call/native-ABI funcref conversions for free, since they all funnel through the same entry point. Worth checking whether the v8/js backends' own from_vm_funcref implementations have the same issue.
Reproduction sketch
- Export/import a WASM function that returns (or receives) a funcref value pointing at the same underlying guest function on every call.
- On the host side, resolve that funcref to a
wasmer::Function (via Value::from_raw, a typed call with a FuncRef/Option<Function> argument or return type, or Table::get) repeatedly, e.g. in a loop or once per incoming request in a server-like workload.
- Observe
StoreObjects.functions (or overall process RSS while the Store stays alive) grow linearly with the number of resolutions rather than staying bounded by the number of distinct funcrefs actually in use.
Summary
Function::from_vm_funcref(sys backend) unconditionally allocates a newStoreHandleand pushes it into the Store's function arena every time it's called, with no caching or deduplication. Since this function is on the hot path for converting any funcref-typed value crossing the host/guest boundary — not just one-time instantiation-time wrapping — a host embedding that repeatedly resolves the same underlying guest function (e.g. a callback passed as an argument, invoked many times) leaks one arena entry per resolution. In a long-running process this grows unboundedly and is never reclaimed.Root cause
lib/api/src/backend/sys/entities/function/mod.rs,Function::from_vm_funcref:StoreHandle::new→InternalStoreHandle::new(lib/vm/src/store.rs) always doeslist.push(val)intoStoreObjects.functions: Vec<VMFunction>:StoreObjectshas no removal/GC method for any of its nine object vectors (functions,tables,globals,instances,memories,extern_objs,exceptions,tags,function_environments) — reasonable for objects that are normally created once at module instantiation, butfrom_vm_funcrefis not limited to that use case.from_vm_funcrefis reachable from every dynamic funcref crossing, not just instantiation:Value::from_raw'sType::FuncRefarm (lib/api/src/entities/value.rs), hit whenever a funcref-typed value (parameter or return value) is marshalled from a raw ABI value.NativeWasmTypeInto::from_abi/from_rawforOption<Function>(lib/api/src/utils/native/convert.rs), on the typed-function/native-ABI call path.Table::get(lib/api/src/backend/sys/entities/table.rs) on any funcref-typed table element, viavalue_from_table_element.Contrast with the sibling
from_vm_extern(same file), used for the same purpose byTable/Global/Memory/Tag:This one just wraps an existing handle/index — zero allocation, no
Vec::push.Functionhas no equivalent path because aVMFuncRefcrosses the boundary as a bare pointer rather than an existing store index, sofrom_vm_funcrefalways fabricates a new entry instead of being able to look one up.The underlying
VMFuncRef's pointer (NonNull<VMCallerCheckedAnyfunc>) is stable per instance-local-function / per hostFunctionfor the life of the instance (traced throughInstance::funcrefs/build_funcrefsinlib/vm/src/instance/mod.rs), andVMFuncRefalready derivesHash/Eq/PartialEqby that pointer's address (lib/vm/src/lib.rs):So the same funcref reliably produces the same pointer value across repeated resolutions — nothing currently deduplicates against it.
Impact
Any embedding that repeatedly converts the same funcref-typed value into a
wasmer::Function— e.g. a host holding a reference to a guest callback function and invoking it many times viaTable::get+Function::call, or any code path that re-derives aValue::FuncReffrom raw ABI values on each call — leaks oneVMFunctionentry (plus a clonedFunctionTypefromSignatureRegistry::lookup_signature, itself heap-allocated) per resolution, for the lifetime of theStore. In a long-running host process handling many such calls, this is unbounded memory growth with no way to reclaim it short of dropping the entireStore.Suggested fix
Add a dedup cache to
StoreObjects(or scoped toFunction::from_vm_funcrefitself) keyed by theVMFuncRef's pointer identity — e.g.HashMap<NonNull<VMCallerCheckedAnyfunc>, InternalStoreHandle<VMFunction>>(or keyed onVMFuncRefdirectly, since it already has the neededHash/Eqimpls) — checked before constructing a newVMFunction/pushing, mirroring howfrom_vm_externalready reuses an existing handle instead of allocating. No lock should be needed sinceStoreObjectsis always accessed via&mut. This would also fixTable::getand the typed-call/native-ABI funcref conversions for free, since they all funnel through the same entry point. Worth checking whether thev8/jsbackends' ownfrom_vm_funcrefimplementations have the same issue.Reproduction sketch
wasmer::Function(viaValue::from_raw, a typed call with aFuncRef/Option<Function>argument or return type, orTable::get) repeatedly, e.g. in a loop or once per incoming request in a server-like workload.StoreObjects.functions(or overall process RSS while theStorestays alive) grow linearly with the number of resolutions rather than staying bounded by the number of distinct funcrefs actually in use.