From 7994d2e9122c5ffef3f69ab09c2f93dea73b744e Mon Sep 17 00:00:00 2001 From: felipegenef Date: Mon, 13 Jul 2026 00:25:42 -0300 Subject: [PATCH] runtime: implement SetFinalizer to fix syscall/js finalizeRef leak --- main_test.go | 12 ++ src/runtime/gc_blocks.go | 38 +++- src/runtime/gc_finalizer.go | 258 +++++++++++++++++++++++++ src/runtime/gc_finalizer_sched.go | 8 + src/runtime/gc_finalizer_sched_none.go | 6 + testdata/finalizer.go | 128 ++++++++++++ testdata/finalizer.txt | 1 + 7 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 src/runtime/gc_finalizer.go create mode 100644 src/runtime/gc_finalizer_sched.go create mode 100644 src/runtime/gc_finalizer_sched_none.go create mode 100644 testdata/finalizer.go create mode 100644 testdata/finalizer.txt diff --git a/main_test.go b/main_test.go index 4316c2dae..e686cc8de 100644 --- a/main_test.go +++ b/main_test.go @@ -59,6 +59,7 @@ func TestBuild(t *testing.T) { "cgo/", "channel.go", "embed/", + "finalizer.go", "float.go", "gc.go", "generics.go", @@ -358,6 +359,17 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) { continue } } + if name == "finalizer.go" && options.Target != "wasm" { + // runtime.SetFinalizer is implemented for the block GC, but the + // test asserts deterministic collection of a dropped object, which + // only holds on the GOOS=js wasm target. The host default GC is + // boehm (SetFinalizer is a no-op there); conservative stack scanning + // on the emulated targets can pin the object; and the wasip2 + // component entry lays out the stack differently, so collection is + // not deterministic on those. The feature still works on all of + // them, it just can't be golden-tested for firing. + continue + } name := name // redefine to avoid race condition t.Run(name, func(t *testing.T) { diff --git a/src/runtime/gc_blocks.go b/src/runtime/gc_blocks.go index 5ed88d462..4d3577e52 100644 --- a/src/runtime/gc_blocks.go +++ b/src/runtime/gc_blocks.go @@ -31,6 +31,7 @@ package runtime // Moss. import ( + "internal/reflectlite" "internal/task" "runtime/interrupt" "unsafe" @@ -484,6 +485,12 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer { // We've claimed this allocation, now we can unlock the heap. gcLock.Unlock() + // If the GC above queued any finalizers, run them now that gcLock is free. + if finalizersQueued { + finalizersQueued = false + wakeFinalizer() + } + // Clear the allocation body. memzero(pointer, size) @@ -534,6 +541,12 @@ func GC() { gcLock.Lock() runGC() gcLock.Unlock() + + // If the GC queued any finalizers, run them now that gcLock is free. + if finalizersQueued { + finalizersQueued = false + wakeFinalizer() + } } // runGC performs a garbage collection cycle. It is the internal implementation @@ -579,6 +592,11 @@ func runGC() (freeBytes uintptr) { finishMark() } + // Detect finalizable objects that became unreachable and queue their + // finalizers. This runs while the world is still stopped, after marking is + // complete and before sweep frees anything. + scanFinalizers() + // If we're using threads, resume all other threads before starting the // sweep. gcResumeWorld() @@ -857,5 +875,23 @@ var count4LUT = [16]uint8{ } func SetFinalizer(obj interface{}, finalizer interface{}) { - // Unimplemented. + // Validate the arguments up front, like the standard library does, so misuse + // fails fast at registration instead of corrupting state when the finalizer + // is later invoked. reflectlite cannot inspect a func's signature, so the + // exact func(*T) match is not checked; the closure ABI is uniform for any + // single pointer argument, which is why callFinalizer can reinterpret it. + if reflectlite.ValueOf(obj).Kind() != reflectlite.Pointer { + runtimePanic("runtime.SetFinalizer: first argument is not a pointer") + } + if finalizer != nil && reflectlite.ValueOf(finalizer).Kind() != reflectlite.Func { + runtimePanic("runtime.SetFinalizer: second argument is not a function") + } + + // For an interface holding a pointer, the value word is the pointer itself. + objPtr := (*_interface)(unsafe.Pointer(&obj)).value + if objPtr == nil { + // A nil pointer has nothing to finalize. + return + } + registerFinalizer(uintptr(objPtr), finalizer) } diff --git a/src/runtime/gc_finalizer.go b/src/runtime/gc_finalizer.go new file mode 100644 index 000000000..13dd6f2c7 --- /dev/null +++ b/src/runtime/gc_finalizer.go @@ -0,0 +1,258 @@ +//go:build gc.conservative || gc.precise + +package runtime + +// This file implements a minimal runtime.SetFinalizer for the block-based +// garbage collector. It supports the common, contract-correct case only: +// +// - SetFinalizer(ptr, func(ptrType)) registers a finalizer that runs once, +// after the object becomes unreachable. +// - SetFinalizer(ptr, nil) clears any finalizer for the object. +// +// It intentionally does not implement full Go finalizer semantics (ordering +// guarantees, cycles, AddCleanup, ...). The whole feature is zero-cost when no +// finalizer is ever registered: the table stays empty, scanFinalizers returns +// immediately, and no background goroutine is spawned. + +import ( + "internal/task" + "unsafe" +) + +// finalizerEntry is one registered finalizer. The same node type is reused for +// the pending queue: when an object dies, its entry is spliced out of the +// registered list and into the pending list with pure pointer operations, so no +// allocation happens during a GC cycle. +type finalizerEntry struct { + next *finalizerEntry + // obj is the object address stored bitwise-NOT (see encodeFinalizerPtr). + obj uintptr + // fn is the finalizer func value. It is kept alive because the registered + // list (a package global) is a GC root, so the boxed closure and any + // captured state survive until the finalizer runs. + fn interface{} +} + +var ( + finalizers *finalizerEntry // registered finalizers; a GC root that keeps fn values alive + finalizerPending *finalizerEntry // finalizers whose object died, waiting to run + numFinalizers uintptr // number of registered finalizers; fast-path gate for scanFinalizers + finalizersQueued bool // set when scanFinalizers queued at least one finalizer to run + finalizerFutex task.Futex // wakes the finalizerRunner goroutine after a GC queues work + finalizerDraining bool // guards against re-entrant inline draining (scheduler.none) + + // finalizerRunnerStarted records whether the background finalizerRunner + // goroutine has been spawned yet. The runner is spawned lazily, on the first + // SetFinalizer, so builds that never register a finalizer let the linker DCE + // the runner and drain machinery. Read/written only under gcLock, so no + // atomics are needed. Unused under scheduler.none (spawnFinalizerRunner is a + // no-op there, and the linker drops the flag). + finalizerRunnerStarted bool +) + +// The object address is stored bitwise-NOT so it never looks like a live heap +// pointer to the conservative scanner. Otherwise the entry would pin every +// finalizable object forever and the object could never be detected as dead. +// Under the precise GC a plain uintptr field is not scanned anyway, so the +// encoding is harmless there and required for the conservative build. +func encodeFinalizerPtr(addr uintptr) uintptr { return ^addr } +func decodeFinalizerPtr(enc uintptr) uintptr { return ^enc } + +// registerFinalizer records fn as the finalizer for the object at addr. A nil fn +// removes any registration for the object. Growing the table (allocating a node) +// is the only allocation and it happens here, on the caller, never during GC. +// gcLock also serializes table access against scanFinalizers, which runs under +// gcLock during a GC on another core/thread. +func registerFinalizer(addr uintptr, fn interface{}) { + enc := encodeFinalizerPtr(addr) + + if fn == nil { + // Clear: remove every registration for this object. + gcLock.Lock() + prev := &finalizers + for n := *prev; n != nil; n = *prev { + if n.obj == enc { + *prev = n.next + numFinalizers-- + } else { + prev = &n.next + } + } + gcLock.Unlock() + return + } + + // Register or replace. The allocation happens before gcLock is taken, + // because alloc acquires gcLock itself. + entry := &finalizerEntry{obj: enc, fn: fn} + gcLock.Lock() + for n := finalizers; n != nil; n = n.next { + if n.obj == enc { + // Replace the finalizer for an already-registered object, so it + // still runs only once (Go SetFinalizer replace semantics). + n.fn = fn + // A finalizer is registered, so make sure the runner exists. The + // flag is serialized by gcLock; the spawn itself allocates, so it + // must run after the lock is released. + spawn := !finalizerRunnerStarted + finalizerRunnerStarted = true + gcLock.Unlock() + if spawn { + spawnFinalizerRunner() + } + return + } + } + entry.next = finalizers + finalizers = entry + numFinalizers++ + // A finalizer is registered, so make sure the runner exists. The flag is + // serialized by gcLock; the spawn itself allocates, so it must run after the + // lock is released. + spawn := !finalizerRunnerStarted + finalizerRunnerStarted = true + gcLock.Unlock() + if spawn { + spawnFinalizerRunner() + } +} + +// scanFinalizers detects finalizable objects that became unreachable in the +// current GC cycle and queues their finalizers. It must be called under gcLock, +// after marking is complete and before sweep frees anything. +func scanFinalizers() { + // Nothing registered and nothing waiting to run: fast path. + if numFinalizers == 0 && finalizerPending == nil { + return + } + + // Detect newly-unreachable objects and move their finalizers to the pending + // queue. + prev := &finalizers + for n := *prev; n != nil; n = *prev { + addr := decodeFinalizerPtr(n.obj) + if !isOnHeap(addr) { + // Not a heap object we can track; keep it registered. + prev = &n.next + continue + } + if blockFromAddr(addr).findHead().state() == blockStateMark { + // Still reachable; keep the finalizer for a later cycle. + prev = &n.next + continue + } + + // The object is unreachable. Splice its entry out of the registered list + // and into the pending queue (alloc-free), so its finalizer runs once. + *prev = n.next + numFinalizers-- + n.next = finalizerPending + finalizerPending = n + finalizersQueued = true + } + + // Resurrect every object whose finalizer is still pending: both the deaths + // found above and any queued by an earlier cycle that the runner has not + // drained yet. Otherwise the next GC would not mark them (their only + // reference is the encoded, scanner-invisible pending entry) and sweep would + // free them out from under a finalizer that hasn't run — a use-after-free. + // Walking the pending list is safe: scanFinalizers and dequeueFinalizer are + // both serialized under gcLock. + var resurrected bool + for n := finalizerPending; n != nil; n = n.next { + markRoot(0, decodeFinalizerPtr(n.obj)) + resurrected = true + } + if resurrected { + // Re-scan so objects reachable only from resurrected objects also + // survive this sweep. + finishMark() + } +} + +// callFinalizer invokes a finalizer func value on the given object pointer. +func callFinalizer(objPtr unsafe.Pointer, fn interface{}) { + // SetFinalizer already validated that fn is a func. A finalizer is + // contractually func(ptrType), and func(*T) and func(unsafe.Pointer) are + // ABI-identical in TinyGo (one pointer arg + trailing context, no result). + // reflect.Value.Call is unimplemented, so reinterpret the boxed closure and + // call it via the same closure-ABI indirect call the runtime uses elsewhere. + fnBox := (*_interface)(unsafe.Pointer(&fn)).value + f := *(*func(unsafe.Pointer))(fnBox) + f(objPtr) +} + +// drainFinalizers runs every queued finalizer, with gcLock released so the +// finalizers may allocate. +func drainFinalizers() { + if finalizerDraining { + // Re-entered from a finalizer that triggered a GC (only possible with + // scheduler.none, which drains inline). Let the outer loop handle any + // newly queued finalizers. + return + } + finalizerDraining = true + for { + n, objPtr := dequeueFinalizer() + if n == nil { + break + } + callFinalizer(objPtr, n.fn) + } + finalizerDraining = false +} + +// dequeueFinalizer pops the next pending finalizer. The pending list is shared +// with scanFinalizers (which runs under gcLock), so the pop is guarded by the +// same lock; the finalizer itself runs afterwards with the lock released. +// +// It also decodes the real object pointer while still holding gcLock and returns +// it. Once the entry leaves finalizerPending it is no longer in the kept-alive +// set, and the only remaining references are the encoded n.obj (invisible to the +// conservative scanner) and n.fn (which for a non-capturing finalizer does not +// reference the object). Materializing the pointer under the lock puts it on the +// caller's stack as a real GC root before any concurrent stop-the-world GC can +// run, so the object cannot be swept out from under callFinalizer. +func dequeueFinalizer() (*finalizerEntry, unsafe.Pointer) { + gcLock.Lock() + n := finalizerPending + var objPtr unsafe.Pointer + if n != nil { + finalizerPending = n.next + objPtr = unsafe.Pointer(decodeFinalizerPtr(n.obj)) + } + gcLock.Unlock() + return n, objPtr +} + +// wakeFinalizer is called after a GC (with gcLock already released) that queued +// finalizers. On schedulers with goroutines it wakes the finalizerRunner; on +// scheduler.none it drains inline. +func wakeFinalizer() { + if hasScheduler || hasParallelism { + // A finalizerRunner exists. Bump the futex before waking so a runner + // caught between draining and waiting doesn't miss this wakeup. + finalizerFutex.Add(1) + finalizerFutex.Wake() + } else { + // scheduler.none: no goroutines, so drain inline. Finalizers must not + // block here; this is safe because gcLock has already been released. + drainFinalizers() + } +} + +// finalizerRunner is the background goroutine that runs finalizers off the +// allocating goroutine's stack. It drains all pending finalizers, then blocks on +// the futex until the next GC queues more. It is spawned lazily by +// spawnFinalizerRunner on the first SetFinalizer, so builds that never register a +// finalizer let the linker eliminate it and the drain machinery entirely. +func finalizerRunner() { + for { + // Sample the futex before draining. A wake that lands after we drain but + // before Wait then leaves the counter changed, so Wait returns at once + // instead of losing the wakeup (at worst one harmless spurious re-drain). + val := finalizerFutex.Load() + drainFinalizers() + finalizerFutex.Wait(val) + } +} diff --git a/src/runtime/gc_finalizer_sched.go b/src/runtime/gc_finalizer_sched.go new file mode 100644 index 000000000..d983decc7 --- /dev/null +++ b/src/runtime/gc_finalizer_sched.go @@ -0,0 +1,8 @@ +//go:build (gc.conservative || gc.precise) && !scheduler.none + +package runtime + +// The go statement lives in this scheduler-gated file, not inline in +// registerFinalizer, so scheduler.none builds never reference internal/task.start +// and the runner is DCE'd when SetFinalizer is unused. +func spawnFinalizerRunner() { go finalizerRunner() } diff --git a/src/runtime/gc_finalizer_sched_none.go b/src/runtime/gc_finalizer_sched_none.go new file mode 100644 index 000000000..55d8a9a05 --- /dev/null +++ b/src/runtime/gc_finalizer_sched_none.go @@ -0,0 +1,6 @@ +//go:build (gc.conservative || gc.precise) && scheduler.none + +package runtime + +// scheduler.none has no goroutines; finalizers drain inline in wakeFinalizer. +func spawnFinalizerRunner() {} diff --git a/testdata/finalizer.go b/testdata/finalizer.go new file mode 100644 index 000000000..c15277785 --- /dev/null +++ b/testdata/finalizer.go @@ -0,0 +1,128 @@ +package main + +// Tests for runtime.SetFinalizer on the block GC. +// +// This test is only run on the precise wasm/wasi targets (see the tests slice +// and the skip in main_test.go): they track stack pointers precisely and +// reliably collect a dropped object, so the finalizer deterministically fires. +// The host default GC is boehm, where SetFinalizer is a no-op, and conservative +// stack scanning on the emulated targets cannot reliably collect the object, so +// firing cannot be asserted there. + +import "runtime" + +type T struct{ x int } + +var ( + ranCount int + clearedRan int + f1Ran int + f2Ran int + sink int +) + +// scrubStack overwrites the stack region used by an alloc-and-drop helper with +// non-pointer words. It must be called at the same call depth as that helper so +// this recursion reuses (and clears) the frame that just held the dropped +// pointer; otherwise a stale copy keeps the object marked and it is never +// collected. The returned value derived from buf keeps the writes live. +// +//go:noinline +func scrubStack(depth int) int { + if depth <= 0 { + return sink + } + var buf [64]int + for i := range buf { + buf[i] = depth + i + } + sink += buf[depth&63] + return scrubStack(depth-1) + buf[0] +} + +// allocAndDrop allocates an object, registers a finalizer, and returns without +// leaking any reference to it, so the object becomes unreachable. The finalizer +// must not capture the object (that would pin it forever): it takes the pointer +// as its argument and touches only a package global. +// +//go:noinline +func allocAndDrop() { + p := &T{x: 42} + runtime.SetFinalizer(p, func(*T) { ranCount++ }) +} + +//go:noinline +func allocRegisterClear() { + p := &T{x: 1} + runtime.SetFinalizer(p, func(*T) { clearedRan++ }) + runtime.SetFinalizer(p, nil) +} + +//go:noinline +func allocRegisterReplace() { + p := &T{x: 2} + runtime.SetFinalizer(p, func(*T) { f1Ran++ }) + runtime.SetFinalizer(p, func(*T) { f2Ran++ }) +} + +// testFires checks that a finalizer runs after its object is collected, and +// only once. scrubStack and the alloc helper are both called here, at the same +// depth, so the scrub clears the helper's stale frame. Gosched lets the +// dedicated finalizer goroutine drain (a no-op under scheduler=none, where +// finalizers already ran inline during GC). +func testFires() { + allocAndDrop() + for i := 0; i < 100 && ranCount == 0; i++ { + sink += scrubStack(40) + runtime.GC() + runtime.Gosched() + } + if ranCount == 0 { + panic("finalizer: never ran after object became unreachable") + } + for i := 0; i < 100; i++ { + sink += scrubStack(40) + runtime.GC() + runtime.Gosched() + } + if ranCount != 1 { + panic("finalizer: ran more than once") + } +} + +// testClear checks that SetFinalizer(obj, nil) removes a finalizer. +func testClear() { + allocRegisterClear() + for i := 0; i < 100; i++ { + sink += scrubStack(40) + runtime.GC() + runtime.Gosched() + } + if clearedRan != 0 { + panic("finalizer: ran after being cleared with nil") + } +} + +// testReplace checks that re-registering replaces the finalizer: only the latest +// one runs, and only once. +func testReplace() { + allocRegisterReplace() + for i := 0; i < 100 && f2Ran == 0; i++ { + sink += scrubStack(40) + runtime.GC() + runtime.Gosched() + } + if f1Ran != 0 { + panic("finalizer: replaced finalizer f1 still ran") + } + if f2Ran != 1 { + panic("finalizer: replacement finalizer f2 did not run exactly once") + } +} + +func main() { + testFires() + testClear() + testReplace() + println("ok") +} diff --git a/testdata/finalizer.txt b/testdata/finalizer.txt new file mode 100644 index 000000000..9766475a4 --- /dev/null +++ b/testdata/finalizer.txt @@ -0,0 +1 @@ +ok