smaeljaish771 opened issue #14451:
Summary
When Winch compiles a typed
selectwhose result is a GC reference, the result's shadow type can incorrectly come from the second operand rather than the declared result type.If the first operand is a non-null
externref, the second operand isref.null extern, and the condition selects the first operand, the runtime value is a real GC reference but its shadow type becomesI32.At a later call site this causes the reference to be omitted from the stack map. With the copying collector, the referenced object can then be reclaimed or relocated without the stack copy being updated.
This was reproduced on the latest commit.
Technical Details
The missing check is the shadow-type filter used to decide whether a value needs a stack map.
needs_stack_map(winch/codegen/src/stack.rs:315-327) is unconditionally false forI32, so a live GC reference whose shadow type isI32neither incrementsgc_ref_countnor satisfies the collection condition inCodeGenContext::calculate_stack_map_offsets(winch/codegen/src/codegen/context.rs:663-695).Because that function then returns an empty table,
FnCall::emit(winch/codegen/src/codegen/call.rs:108-113) skips emitting any stack map.The relevant data flow is:
visit_ref_null(winch/codegen/src/visitor.rs:2232-2252) pushesref.null extern/exnasVal::i32(0).visit_typed_select(winch/codegen/src/visitor.rs:2228-2230) ignores the instruction's declared result type_tyand dispatches tovisit_select.visit_select(winch/codegen/src/visitor.rs:2208-2226) pushes the result using the shadow type of the second operand (stack.push(val2.into())).- When operand 1 is a real GC reference, operand 2 is
ref.null, and the condition is non-zero, the runtime result is the reference but its shadow type isI32.- At the next call,
FnCall::emitspills the value, butcalculate_stack_map_offsetsreturns an empty table, so no stack map is emitted.- During collection,
Store::trace_wasm_stack_frame(crates/wasmtime/src/runtime/store/gc.rs:771-786) only treats slots present in the stack map as Wasm stack roots.Reproduction
Tested at commit:
ff7896b6d97a424aed430a48a186773fd163667fThe reproducer uses the public embedding API with Winch and compares a normal reference path with the typed-select path.
Guest input:
(module (import "" "make" (func $make (result externref))) (import "" "gc" (func $gc)) (func (export "control") (result externref) call $make call $gc) (func (export "bug") (result externref) call $make ref.null extern i32.const 1 select (result externref) call $gc) )With the null collector, both cases survive.
With the copying collector, the control case survives while the typed-select case loses the referenced object:
collector=null [control] data=Ok(0xdecaf) SURVIVED [bug] data=Ok(0xdecaf) SURVIVED collector=copying [control] data=Ok(0xdecaf) SURVIVED [bug] data=Err(BUG: invalid `ExternRefHostDataId`) HOST-DATA-SWEPT SUMMARY null_bug=Survived copying_control=Survived copying_bug=Swept PROBE_RESULT: reproducedThe two cases differ only in whether the reference passes through the typed
select, which appears to isolate the problem to the shadow type used for the select result.Suggested Fix
visit_typed_select(winch/codegen/src/visitor.rs:2228-2230) should honor the instruction's declared result type instead of discarding_ty.
visit_select(winch/codegen/src/visitor.rs:2208-2226) should not derive the pushed shadow type from the second operand when the declared result is a reference type. The pushed value needs to retain a reference shadow type so thatneeds_stack_mapreturns true and the live reference is included in the call-site stack map.
smaeljaish771 added the bug label to Issue #14451.
alexcrichton commented on issue #14451:
cc @saulecabrera @macovedj
alexcrichton added the wasm-proposal:gc label to Issue #14451.
alexcrichton added the winch label to Issue #14451.
saulecabrera closed issue #14451:
Summary
When Winch compiles a typed
selectwhose result is a GC reference, the result's shadow type can incorrectly come from the second operand rather than the declared result type.If the first operand is a non-null
externref, the second operand isref.null extern, and the condition selects the first operand, the runtime value is a real GC reference but its shadow type becomesI32.At a later call site this causes the reference to be omitted from the stack map. With the copying collector, the referenced object can then be reclaimed or relocated without the stack copy being updated.
This was reproduced on the latest commit.
Technical Details
The missing check is the shadow-type filter used to decide whether a value needs a stack map.
needs_stack_map(winch/codegen/src/stack.rs:315-327) is unconditionally false forI32, so a live GC reference whose shadow type isI32neither incrementsgc_ref_countnor satisfies the collection condition inCodeGenContext::calculate_stack_map_offsets(winch/codegen/src/codegen/context.rs:663-695).Because that function then returns an empty table,
FnCall::emit(winch/codegen/src/codegen/call.rs:108-113) skips emitting any stack map.The relevant data flow is:
visit_ref_null(winch/codegen/src/visitor.rs:2232-2252) pushesref.null extern/exnasVal::i32(0).visit_typed_select(winch/codegen/src/visitor.rs:2228-2230) ignores the instruction's declared result type_tyand dispatches tovisit_select.visit_select(winch/codegen/src/visitor.rs:2208-2226) pushes the result using the shadow type of the second operand (stack.push(val2.into())).- When operand 1 is a real GC reference, operand 2 is
ref.null, and the condition is non-zero, the runtime result is the reference but its shadow type isI32.- At the next call,
FnCall::emitspills the value, butcalculate_stack_map_offsetsreturns an empty table, so no stack map is emitted.- During collection,
Store::trace_wasm_stack_frame(crates/wasmtime/src/runtime/store/gc.rs:771-786) only treats slots present in the stack map as Wasm stack roots.Reproduction
Tested at commit:
ff7896b6d97a424aed430a48a186773fd163667fThe reproducer uses the public embedding API with Winch and compares a normal reference path with the typed-select path.
Guest input:
(module (import "" "make" (func $make (result externref))) (import "" "gc" (func $gc)) (func (export "control") (result externref) call $make call $gc) (func (export "bug") (result externref) call $make ref.null extern i32.const 1 select (result externref) call $gc) )With the null collector, both cases survive.
With the copying collector, the control case survives while the typed-select case loses the referenced object:
collector=null [control] data=Ok(0xdecaf) SURVIVED [bug] data=Ok(0xdecaf) SURVIVED collector=copying [control] data=Ok(0xdecaf) SURVIVED [bug] data=Err(BUG: invalid `ExternRefHostDataId`) HOST-DATA-SWEPT SUMMARY null_bug=Survived copying_control=Survived copying_bug=Swept PROBE_RESULT: reproducedThe two cases differ only in whether the reference passes through the typed
select, which appears to isolate the problem to the shadow type used for the select result.Suggested Fix
visit_typed_select(winch/codegen/src/visitor.rs:2228-2230) should honor the instruction's declared result type instead of discarding_ty.
visit_select(winch/codegen/src/visitor.rs:2208-2226) should not derive the pushed shadow type from the second operand when the declared result is a reference type. The pushed value needs to retain a reference shadow type so thatneeds_stack_mapreturns true and the live reference is included in the call-site stack map.
Last updated: Oct 11 2026 at 04:10 UTC