Skip to content

Commit 7994d2e

Browse files
felipegenefdeadprogram
authored andcommitted
runtime: implement SetFinalizer to fix syscall/js finalizeRef leak
1 parent f1c39e8 commit 7994d2e

7 files changed

Lines changed: 450 additions & 1 deletion

File tree

main_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func TestBuild(t *testing.T) {
5959
"cgo/",
6060
"channel.go",
6161
"embed/",
62+
"finalizer.go",
6263
"float.go",
6364
"gc.go",
6465
"generics.go",
@@ -358,6 +359,17 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) {
358359
continue
359360
}
360361
}
362+
if name == "finalizer.go" && options.Target != "wasm" {
363+
// runtime.SetFinalizer is implemented for the block GC, but the
364+
// test asserts deterministic collection of a dropped object, which
365+
// only holds on the GOOS=js wasm target. The host default GC is
366+
// boehm (SetFinalizer is a no-op there); conservative stack scanning
367+
// on the emulated targets can pin the object; and the wasip2
368+
// component entry lays out the stack differently, so collection is
369+
// not deterministic on those. The feature still works on all of
370+
// them, it just can't be golden-tested for firing.
371+
continue
372+
}
361373

362374
name := name // redefine to avoid race condition
363375
t.Run(name, func(t *testing.T) {

src/runtime/gc_blocks.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ package runtime
3131
// Moss.
3232

3333
import (
34+
"internal/reflectlite"
3435
"internal/task"
3536
"runtime/interrupt"
3637
"unsafe"
@@ -484,6 +485,12 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer {
484485
// We've claimed this allocation, now we can unlock the heap.
485486
gcLock.Unlock()
486487

488+
// If the GC above queued any finalizers, run them now that gcLock is free.
489+
if finalizersQueued {
490+
finalizersQueued = false
491+
wakeFinalizer()
492+
}
493+
487494
// Clear the allocation body.
488495
memzero(pointer, size)
489496

@@ -534,6 +541,12 @@ func GC() {
534541
gcLock.Lock()
535542
runGC()
536543
gcLock.Unlock()
544+
545+
// If the GC queued any finalizers, run them now that gcLock is free.
546+
if finalizersQueued {
547+
finalizersQueued = false
548+
wakeFinalizer()
549+
}
537550
}
538551

539552
// runGC performs a garbage collection cycle. It is the internal implementation
@@ -579,6 +592,11 @@ func runGC() (freeBytes uintptr) {
579592
finishMark()
580593
}
581594

595+
// Detect finalizable objects that became unreachable and queue their
596+
// finalizers. This runs while the world is still stopped, after marking is
597+
// complete and before sweep frees anything.
598+
scanFinalizers()
599+
582600
// If we're using threads, resume all other threads before starting the
583601
// sweep.
584602
gcResumeWorld()
@@ -857,5 +875,23 @@ var count4LUT = [16]uint8{
857875
}
858876

859877
func SetFinalizer(obj interface{}, finalizer interface{}) {
860-
// Unimplemented.
878+
// Validate the arguments up front, like the standard library does, so misuse
879+
// fails fast at registration instead of corrupting state when the finalizer
880+
// is later invoked. reflectlite cannot inspect a func's signature, so the
881+
// exact func(*T) match is not checked; the closure ABI is uniform for any
882+
// single pointer argument, which is why callFinalizer can reinterpret it.
883+
if reflectlite.ValueOf(obj).Kind() != reflectlite.Pointer {
884+
runtimePanic("runtime.SetFinalizer: first argument is not a pointer")
885+
}
886+
if finalizer != nil && reflectlite.ValueOf(finalizer).Kind() != reflectlite.Func {
887+
runtimePanic("runtime.SetFinalizer: second argument is not a function")
888+
}
889+
890+
// For an interface holding a pointer, the value word is the pointer itself.
891+
objPtr := (*_interface)(unsafe.Pointer(&obj)).value
892+
if objPtr == nil {
893+
// A nil pointer has nothing to finalize.
894+
return
895+
}
896+
registerFinalizer(uintptr(objPtr), finalizer)
861897
}

src/runtime/gc_finalizer.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
//go:build gc.conservative || gc.precise
2+
3+
package runtime
4+
5+
// This file implements a minimal runtime.SetFinalizer for the block-based
6+
// garbage collector. It supports the common, contract-correct case only:
7+
//
8+
// - SetFinalizer(ptr, func(ptrType)) registers a finalizer that runs once,
9+
// after the object becomes unreachable.
10+
// - SetFinalizer(ptr, nil) clears any finalizer for the object.
11+
//
12+
// It intentionally does not implement full Go finalizer semantics (ordering
13+
// guarantees, cycles, AddCleanup, ...). The whole feature is zero-cost when no
14+
// finalizer is ever registered: the table stays empty, scanFinalizers returns
15+
// immediately, and no background goroutine is spawned.
16+
17+
import (
18+
"internal/task"
19+
"unsafe"
20+
)
21+
22+
// finalizerEntry is one registered finalizer. The same node type is reused for
23+
// the pending queue: when an object dies, its entry is spliced out of the
24+
// registered list and into the pending list with pure pointer operations, so no
25+
// allocation happens during a GC cycle.
26+
type finalizerEntry struct {
27+
next *finalizerEntry
28+
// obj is the object address stored bitwise-NOT (see encodeFinalizerPtr).
29+
obj uintptr
30+
// fn is the finalizer func value. It is kept alive because the registered
31+
// list (a package global) is a GC root, so the boxed closure and any
32+
// captured state survive until the finalizer runs.
33+
fn interface{}
34+
}
35+
36+
var (
37+
finalizers *finalizerEntry // registered finalizers; a GC root that keeps fn values alive
38+
finalizerPending *finalizerEntry // finalizers whose object died, waiting to run
39+
numFinalizers uintptr // number of registered finalizers; fast-path gate for scanFinalizers
40+
finalizersQueued bool // set when scanFinalizers queued at least one finalizer to run
41+
finalizerFutex task.Futex // wakes the finalizerRunner goroutine after a GC queues work
42+
finalizerDraining bool // guards against re-entrant inline draining (scheduler.none)
43+
44+
// finalizerRunnerStarted records whether the background finalizerRunner
45+
// goroutine has been spawned yet. The runner is spawned lazily, on the first
46+
// SetFinalizer, so builds that never register a finalizer let the linker DCE
47+
// the runner and drain machinery. Read/written only under gcLock, so no
48+
// atomics are needed. Unused under scheduler.none (spawnFinalizerRunner is a
49+
// no-op there, and the linker drops the flag).
50+
finalizerRunnerStarted bool
51+
)
52+
53+
// The object address is stored bitwise-NOT so it never looks like a live heap
54+
// pointer to the conservative scanner. Otherwise the entry would pin every
55+
// finalizable object forever and the object could never be detected as dead.
56+
// Under the precise GC a plain uintptr field is not scanned anyway, so the
57+
// encoding is harmless there and required for the conservative build.
58+
func encodeFinalizerPtr(addr uintptr) uintptr { return ^addr }
59+
func decodeFinalizerPtr(enc uintptr) uintptr { return ^enc }
60+
61+
// registerFinalizer records fn as the finalizer for the object at addr. A nil fn
62+
// removes any registration for the object. Growing the table (allocating a node)
63+
// is the only allocation and it happens here, on the caller, never during GC.
64+
// gcLock also serializes table access against scanFinalizers, which runs under
65+
// gcLock during a GC on another core/thread.
66+
func registerFinalizer(addr uintptr, fn interface{}) {
67+
enc := encodeFinalizerPtr(addr)
68+
69+
if fn == nil {
70+
// Clear: remove every registration for this object.
71+
gcLock.Lock()
72+
prev := &finalizers
73+
for n := *prev; n != nil; n = *prev {
74+
if n.obj == enc {
75+
*prev = n.next
76+
numFinalizers--
77+
} else {
78+
prev = &n.next
79+
}
80+
}
81+
gcLock.Unlock()
82+
return
83+
}
84+
85+
// Register or replace. The allocation happens before gcLock is taken,
86+
// because alloc acquires gcLock itself.
87+
entry := &finalizerEntry{obj: enc, fn: fn}
88+
gcLock.Lock()
89+
for n := finalizers; n != nil; n = n.next {
90+
if n.obj == enc {
91+
// Replace the finalizer for an already-registered object, so it
92+
// still runs only once (Go SetFinalizer replace semantics).
93+
n.fn = fn
94+
// A finalizer is registered, so make sure the runner exists. The
95+
// flag is serialized by gcLock; the spawn itself allocates, so it
96+
// must run after the lock is released.
97+
spawn := !finalizerRunnerStarted
98+
finalizerRunnerStarted = true
99+
gcLock.Unlock()
100+
if spawn {
101+
spawnFinalizerRunner()
102+
}
103+
return
104+
}
105+
}
106+
entry.next = finalizers
107+
finalizers = entry
108+
numFinalizers++
109+
// A finalizer is registered, so make sure the runner exists. The flag is
110+
// serialized by gcLock; the spawn itself allocates, so it must run after the
111+
// lock is released.
112+
spawn := !finalizerRunnerStarted
113+
finalizerRunnerStarted = true
114+
gcLock.Unlock()
115+
if spawn {
116+
spawnFinalizerRunner()
117+
}
118+
}
119+
120+
// scanFinalizers detects finalizable objects that became unreachable in the
121+
// current GC cycle and queues their finalizers. It must be called under gcLock,
122+
// after marking is complete and before sweep frees anything.
123+
func scanFinalizers() {
124+
// Nothing registered and nothing waiting to run: fast path.
125+
if numFinalizers == 0 && finalizerPending == nil {
126+
return
127+
}
128+
129+
// Detect newly-unreachable objects and move their finalizers to the pending
130+
// queue.
131+
prev := &finalizers
132+
for n := *prev; n != nil; n = *prev {
133+
addr := decodeFinalizerPtr(n.obj)
134+
if !isOnHeap(addr) {
135+
// Not a heap object we can track; keep it registered.
136+
prev = &n.next
137+
continue
138+
}
139+
if blockFromAddr(addr).findHead().state() == blockStateMark {
140+
// Still reachable; keep the finalizer for a later cycle.
141+
prev = &n.next
142+
continue
143+
}
144+
145+
// The object is unreachable. Splice its entry out of the registered list
146+
// and into the pending queue (alloc-free), so its finalizer runs once.
147+
*prev = n.next
148+
numFinalizers--
149+
n.next = finalizerPending
150+
finalizerPending = n
151+
finalizersQueued = true
152+
}
153+
154+
// Resurrect every object whose finalizer is still pending: both the deaths
155+
// found above and any queued by an earlier cycle that the runner has not
156+
// drained yet. Otherwise the next GC would not mark them (their only
157+
// reference is the encoded, scanner-invisible pending entry) and sweep would
158+
// free them out from under a finalizer that hasn't run — a use-after-free.
159+
// Walking the pending list is safe: scanFinalizers and dequeueFinalizer are
160+
// both serialized under gcLock.
161+
var resurrected bool
162+
for n := finalizerPending; n != nil; n = n.next {
163+
markRoot(0, decodeFinalizerPtr(n.obj))
164+
resurrected = true
165+
}
166+
if resurrected {
167+
// Re-scan so objects reachable only from resurrected objects also
168+
// survive this sweep.
169+
finishMark()
170+
}
171+
}
172+
173+
// callFinalizer invokes a finalizer func value on the given object pointer.
174+
func callFinalizer(objPtr unsafe.Pointer, fn interface{}) {
175+
// SetFinalizer already validated that fn is a func. A finalizer is
176+
// contractually func(ptrType), and func(*T) and func(unsafe.Pointer) are
177+
// ABI-identical in TinyGo (one pointer arg + trailing context, no result).
178+
// reflect.Value.Call is unimplemented, so reinterpret the boxed closure and
179+
// call it via the same closure-ABI indirect call the runtime uses elsewhere.
180+
fnBox := (*_interface)(unsafe.Pointer(&fn)).value
181+
f := *(*func(unsafe.Pointer))(fnBox)
182+
f(objPtr)
183+
}
184+
185+
// drainFinalizers runs every queued finalizer, with gcLock released so the
186+
// finalizers may allocate.
187+
func drainFinalizers() {
188+
if finalizerDraining {
189+
// Re-entered from a finalizer that triggered a GC (only possible with
190+
// scheduler.none, which drains inline). Let the outer loop handle any
191+
// newly queued finalizers.
192+
return
193+
}
194+
finalizerDraining = true
195+
for {
196+
n, objPtr := dequeueFinalizer()
197+
if n == nil {
198+
break
199+
}
200+
callFinalizer(objPtr, n.fn)
201+
}
202+
finalizerDraining = false
203+
}
204+
205+
// dequeueFinalizer pops the next pending finalizer. The pending list is shared
206+
// with scanFinalizers (which runs under gcLock), so the pop is guarded by the
207+
// same lock; the finalizer itself runs afterwards with the lock released.
208+
//
209+
// It also decodes the real object pointer while still holding gcLock and returns
210+
// it. Once the entry leaves finalizerPending it is no longer in the kept-alive
211+
// set, and the only remaining references are the encoded n.obj (invisible to the
212+
// conservative scanner) and n.fn (which for a non-capturing finalizer does not
213+
// reference the object). Materializing the pointer under the lock puts it on the
214+
// caller's stack as a real GC root before any concurrent stop-the-world GC can
215+
// run, so the object cannot be swept out from under callFinalizer.
216+
func dequeueFinalizer() (*finalizerEntry, unsafe.Pointer) {
217+
gcLock.Lock()
218+
n := finalizerPending
219+
var objPtr unsafe.Pointer
220+
if n != nil {
221+
finalizerPending = n.next
222+
objPtr = unsafe.Pointer(decodeFinalizerPtr(n.obj))
223+
}
224+
gcLock.Unlock()
225+
return n, objPtr
226+
}
227+
228+
// wakeFinalizer is called after a GC (with gcLock already released) that queued
229+
// finalizers. On schedulers with goroutines it wakes the finalizerRunner; on
230+
// scheduler.none it drains inline.
231+
func wakeFinalizer() {
232+
if hasScheduler || hasParallelism {
233+
// A finalizerRunner exists. Bump the futex before waking so a runner
234+
// caught between draining and waiting doesn't miss this wakeup.
235+
finalizerFutex.Add(1)
236+
finalizerFutex.Wake()
237+
} else {
238+
// scheduler.none: no goroutines, so drain inline. Finalizers must not
239+
// block here; this is safe because gcLock has already been released.
240+
drainFinalizers()
241+
}
242+
}
243+
244+
// finalizerRunner is the background goroutine that runs finalizers off the
245+
// allocating goroutine's stack. It drains all pending finalizers, then blocks on
246+
// the futex until the next GC queues more. It is spawned lazily by
247+
// spawnFinalizerRunner on the first SetFinalizer, so builds that never register a
248+
// finalizer let the linker eliminate it and the drain machinery entirely.
249+
func finalizerRunner() {
250+
for {
251+
// Sample the futex before draining. A wake that lands after we drain but
252+
// before Wait then leaves the counter changed, so Wait returns at once
253+
// instead of losing the wakeup (at worst one harmless spurious re-drain).
254+
val := finalizerFutex.Load()
255+
drainFinalizers()
256+
finalizerFutex.Wait(val)
257+
}
258+
}

src/runtime/gc_finalizer_sched.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//go:build (gc.conservative || gc.precise) && !scheduler.none
2+
3+
package runtime
4+
5+
// The go statement lives in this scheduler-gated file, not inline in
6+
// registerFinalizer, so scheduler.none builds never reference internal/task.start
7+
// and the runner is DCE'd when SetFinalizer is unused.
8+
func spawnFinalizerRunner() { go finalizerRunner() }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
//go:build (gc.conservative || gc.precise) && scheduler.none
2+
3+
package runtime
4+
5+
// scheduler.none has no goroutines; finalizers drain inline in wakeFinalizer.
6+
func spawnFinalizerRunner() {}

0 commit comments

Comments
 (0)