A reference stored in a global is not a GC root: freed while still reachable
Version: master, commit 8849867 (2026-09-23), Linux x64, default build
(WALRUS_MODE=release, WALRUS_GC=ON). Interpreter and --jit both affected.
Reproduce
(module
(type $s (struct (field (mut i32))))
(type $a (array (mut i32)))
(global $g (mut (ref null $s)) (ref.null $s))
(func (export "run") (result i32) (local $i i32)
(global.set $g (struct.new $s (i32.const 305419896)))
(loop $l ;; allocate ~200 MB of garbage
(drop (array.new_default $a (i32.const 65536)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const 3000))))
(struct.get $s 0 (global.get $g))))
(assert_return (invoke "run") (i32.const 305419896))
$ walrus --enable-web-assembly3 repro.wast
Assertion failed at 2: run() expected 305419896, but got 0
V8, SpiderMonkey and JavaScriptCore return 305419896. With collection
disabled (GC_DONT_GC=1 walrus ...) Walrus returns 305419896 too, so the
semantics are right and the collector is freeing a live object.
The same object parked in a local, a table slot, a struct field, a ref array
or an element segment survives. Only global.set at run time loses it.
Cause
Global::createGlobal in src/runtime/Global.h does new Global(value, type).
That is plain malloc, which bdwgc does not scan, so the Value held by the
Global is invisible to the marker and its object is collected at the next
collection. Table and Instance allocate their reference slots with
GC_MALLOC_UNCOLLECTABLE for exactly this reason (Table.cpp:59,
Instance.cpp:46); Global does not.
A global initialised by a constant expression only appears to work because
the fresh object is still on the C stack or in a register during the first
collections.
Impact
Any program that keeps a struct or array only in a global across an
allocation-heavy phase reads freed memory. On larger generated modules we see
wrong values that differ from run to run, and once the block is reused, a
SIGSEGV in the JIT inside bdwgc's finalizer walk.
Fix that works for us
Allocate Global in collector-visible memory, e.g. an operator new on
GC_MALLOC_UNCOLLECTABLE (and operator delete on GC_FREE) under
ENABLE_GC.
Ack
Will be updated
A reference stored in a global is not a GC root: freed while still reachable
Version: master, commit 8849867 (2026-09-23), Linux x64, default build
(
WALRUS_MODE=release,WALRUS_GC=ON). Interpreter and--jitboth affected.Reproduce
V8, SpiderMonkey and JavaScriptCore return 305419896. With collection
disabled (
GC_DONT_GC=1 walrus ...) Walrus returns 305419896 too, so thesemantics are right and the collector is freeing a live object.
The same object parked in a local, a table slot, a struct field, a ref array
or an element segment survives. Only
global.setat run time loses it.Cause
Global::createGlobalinsrc/runtime/Global.hdoesnew Global(value, type).That is plain malloc, which bdwgc does not scan, so the
Valueheld by theGlobal is invisible to the marker and its object is collected at the next
collection.
TableandInstanceallocate their reference slots withGC_MALLOC_UNCOLLECTABLEfor exactly this reason (Table.cpp:59,Instance.cpp:46);Globaldoes not.A global initialised by a constant expression only appears to work because
the fresh object is still on the C stack or in a register during the first
collections.
Impact
Any program that keeps a struct or array only in a global across an
allocation-heavy phase reads freed memory. On larger generated modules we see
wrong values that differ from run to run, and once the block is reused, a
SIGSEGV in the JIT inside bdwgc's finalizer walk.
Fix that works for us
Allocate
Globalin collector-visible memory, e.g. anoperator newonGC_MALLOC_UNCOLLECTABLE(andoperator deleteonGC_FREE) underENABLE_GC.Ack
Will be updated