From 86521120f05daa1279a48ef18a7fad82e14ea52b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:00:13 -0700 Subject: [PATCH 1/3] runtime: run finalizers with Boehm GC Boehm currently ignores SetFinalizer, leaving syscall/js values and other external resources permanently referenced. Finalizers need to run after collection and prompt collections for external allocations. A second registration list would duplicate Boehm ownership and make registration and dispatch scale linearly. Starting empty finalizer runners on parallel schedulers also adds unnecessary thread overhead. For 5788 --- compileopts/finalizer_coverage_test.go | 30 ++-- main_test.go | 32 +++-- src/runtime/gc_boehm.c | 15 ++ src/runtime/gc_boehm.go | 181 +++++++++++++++++++++++- src/runtime/gc_finalizer.go | 32 +---- src/runtime/gc_finalizer_common.go | 29 ++++ src/runtime/gc_finalizer_sched.go | 2 +- src/runtime/gc_finalizer_sched_none.go | 2 +- src/runtime/gc_finalizer_sched_other.go | 2 +- testdata/finalizer.go | 9 -- testdata/finalizerlarge.go | 83 ++++++++++- 11 files changed, 347 insertions(+), 70 deletions(-) create mode 100644 src/runtime/gc_finalizer_common.go diff --git a/compileopts/finalizer_coverage_test.go b/compileopts/finalizer_coverage_test.go index bf12e0c5e4..192468838a 100644 --- a/compileopts/finalizer_coverage_test.go +++ b/compileopts/finalizer_coverage_test.go @@ -21,22 +21,22 @@ func TestFinalizerRunnerSchedulerCoverage(t *testing.T) { exprs[i] = readBuildConstraint(t, filepath.Join("..", "src", "runtime", name)) } - for _, sched := range validSchedulerOptions { - // The finalizer table exists under block GCs. - // gc.conservative satisfies the GC condition in every constraint. - tags := map[string]bool{ - "gc.conservative": true, - "scheduler." + sched: true, - } - var matched []string - for i, expr := range exprs { - if expr.Eval(func(tag string) bool { return tags[tag] }) { - matched = append(matched, files[i]) + for _, gc := range []string{"conservative", "precise", "boehm"} { + for _, sched := range validSchedulerOptions { + tags := map[string]bool{ + "gc." + gc: true, + "scheduler." + sched: true, + } + var matched []string + for i, expr := range exprs { + if expr.Eval(func(tag string) bool { return tags[tag] }) { + matched = append(matched, files[i]) + } + } + if len(matched) != 1 { + t.Errorf("gc.%s scheduler.%s: spawnFinalizerRunner defined in %d files %v, want exactly 1", + gc, sched, len(matched), matched) } - } - if len(matched) != 1 { - t.Errorf("scheduler.%s: spawnFinalizerRunner defined in %d files %v, want exactly 1", - sched, len(matched), matched) } } } diff --git a/main_test.go b/main_test.go index 2a915347c0..618c57ff17 100644 --- a/main_test.go +++ b/main_test.go @@ -418,6 +418,30 @@ func TestBuild(t *testing.T) { } }) + t.Run("boehm-finalizers", func(t *testing.T) { + t.Parallel() + options := optionsFromTarget("wasm", sema) + options.GC = "boehm" + t.Run("finalizerlarge.go-graph", func(t *testing.T) { + t.Parallel() + runTest("finalizerlarge.go", compileopts.Options(options), t, []string{"graph"}, nil) + }) + for _, name := range []string{ + "finalizer.go", + "finalizerbits.go", + "finalizeridle.go", + "finalizerlarge.go", + "finalizerinvariants.go", + } { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + testOptions := compileopts.Options(options) + runTest(name, testOptions, t, nil, nil) + }) + } + }) + // Test a few build options. t.Run("build-options", func(t *testing.T) { t.Parallel() @@ -777,14 +801,6 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) { continue } } - if options.Target == "" && options.GC == "" { - switch name { - case "finalizerinvariants.go": - // Skip the default host GC because it does not implement finalizers. - // Explicit conservative GC variants cover this test. - continue - } - } if options.Target == "simavr" { switch name { case "finalizerinvariants.go": diff --git a/src/runtime/gc_boehm.c b/src/runtime/gc_boehm.c index 6d4fec8ec2..666adb7822 100644 --- a/src/runtime/gc_boehm.c +++ b/src/runtime/gc_boehm.c @@ -10,6 +10,18 @@ #include void tinygo_runtime_bdwgc_callback(void); +void tinygo_runtime_bdwgc_finalizer(void *, void *); + +static void GC_CALLBACK finalizer_callback(void *obj, void *data) { + tinygo_runtime_bdwgc_finalizer(obj, data); +} + +uintptr_t tinygo_runtime_bdwgc_register_finalizer(uintptr_t obj, uintptr_t data) { + void *old_data = (void *)(uintptr_t)-1; + GC_register_finalizer_no_order((void *)obj, data ? finalizer_callback : NULL, + (void *)data, NULL, &old_data); + return (uintptr_t)old_data; +} struct descriptor_cache_entry { uintptr_t layout; @@ -74,6 +86,9 @@ static void GC_CALLBACK warn_proc(const char *msg, GC_word arg) { void tinygo_runtime_bdwgc_init(void) { GC_set_push_other_roots(callback); + GC_set_finalize_on_demand(1); + // See gc.h GC_set_java_finalization for unordered finalizer reachability. + GC_set_java_finalization(1); #if defined(__wasm__) // There are a lot of warnings on WebAssembly in the form: // diff --git a/src/runtime/gc_boehm.go b/src/runtime/gc_boehm.go index 0e91e0f09e..ce8a69e3e2 100644 --- a/src/runtime/gc_boehm.go +++ b/src/runtime/gc_boehm.go @@ -20,6 +20,7 @@ package runtime import ( "internal/gclayout" + "internal/reflectlite" "internal/task" "unsafe" ) @@ -37,6 +38,21 @@ var ( gcMallocs uint64 ) +type boehmFinalizer struct { + next *boehmFinalizer + fn interface{} + ptr unsafe.Pointer // keeps the object alive after Boehm dequeues its callback +} + +var ( + finalizerPending *boehmFinalizer + numFinalizers uintptr + finalizersSinceGC uintptr + finalizerQueued bool + finalizerDraining bool + finalizerRunnerStarted bool +) + func initHeap() { libgc_init() @@ -123,8 +139,12 @@ func alloc(size uintptr, layout unsafe.Pointer) unsafe.Pointer { ) } } + queued := boehmInvokeFinalizers() gcResumeWorld() gcLock.Unlock() + if queued { + wakeFinalizer() + } if ptr == nil { runtimeFatal("gc: out of memory") return nil @@ -150,8 +170,12 @@ func allocManual(size uintptr) unsafe.Pointer { gcLock.Lock() ptr := libgc_malloc_atomic_uncollectable(size) + queued := boehmInvokeFinalizers() gcResumeWorld() gcLock.Unlock() + if queued { + wakeFinalizer() + } if ptr == nil { runtimeFatal("gc: out of memory") return nil @@ -163,8 +187,12 @@ func allocManual(size uintptr) unsafe.Pointer { func free(ptr unsafe.Pointer) { gcLock.Lock() libgc_free(ptr) + queued := boehmInvokeFinalizers() gcResumeWorld() gcLock.Unlock() + if queued { + wakeFinalizer() + } } //go:noinline @@ -175,8 +203,13 @@ func freeTaskStack(ptr uintptr) { func GC() { gcLock.Lock() libgc_gcollect() + finalizersSinceGC = 0 + queued := boehmInvokeFinalizers() gcResumeWorld() gcLock.Unlock() + if queued { + wakeFinalizer() + } } // This should be stack-allocated, but we don't currently have a good way of @@ -216,11 +249,153 @@ func setHeapEnd(newHeapEnd uintptr) { } func SetFinalizer(obj interface{}, finalizer interface{}) { - // Unimplemented. - // The GC *does* support finalization, so this could be added relatively - // easily I think. + if reflectlite.ValueOf(obj).Kind() != reflectlite.Pointer { + runtimeFatal("runtime.SetFinalizer: first argument is not a pointer") + } + if finalizer != nil && reflectlite.ValueOf(finalizer).Kind() != reflectlite.Func { + runtimeFatal("runtime.SetFinalizer: second argument is not a function") + } + objPtr := (*_interface)(unsafe.Pointer(&obj)).value + if objPtr == nil { + return + } + + var entry *boehmFinalizer + if finalizer != nil { + entry = &boehmFinalizer{fn: finalizer} + } + gcLock.Lock() + // Only the base of a collectible allocation can be finalized. + if libgc_base(uintptr(objPtr)) != uintptr(objPtr) { + gcLock.Unlock() + return + } + old := libgc_register_finalizer(uintptr(objPtr), uintptr(unsafe.Pointer(entry))) + if old == ^uintptr(0) { + runtimeFatal("gc: cannot register finalizer") + } + if old != 0 { + (*boehmFinalizer)(unsafe.Pointer(old)).fn = nil + } + if entry != nil && old == 0 { + numFinalizers++ + finalizersSinceGC++ + } else if entry == nil && old != 0 { + numFinalizers-- + if finalizersSinceGC != 0 { + finalizersSinceGC-- + } + } + spawn := entry != nil && !hasParallelism && !finalizerRunnerStarted + if spawn { + finalizerRunnerStarted = true + } + gcResumeWorld() + gcLock.Unlock() + if spawn { + spawnFinalizerRunner() + } + KeepAlive(obj) } +//export tinygo_runtime_bdwgc_finalizer +func boehmQueueFinalizer(obj unsafe.Pointer, data unsafe.Pointer) { + n := (*boehmFinalizer)(data) + numFinalizers-- + if n.fn != nil { + n.ptr = obj + n.next = finalizerPending + finalizerPending = n + finalizerQueued = true + } +} + +// Call with gcLock held. Callbacks only enqueue Go work; they never run user code. +func boehmInvokeFinalizers() bool { + if numFinalizers != 0 && libgc_should_invoke_finalizers() != 0 { + libgc_invoke_finalizers() + } + queued := finalizerQueued + finalizerQueued = false + return queued +} + +func finalizerPressureGC() bool { + gcLock.Lock() + trigger := finalizerGCTrigger(numFinalizers) + if trigger == 0 || finalizersSinceGC < trigger { + gcLock.Unlock() + return false + } + libgc_gcollect() + finalizersSinceGC = 0 + queued := boehmInvokeFinalizers() + gcResumeWorld() + gcLock.Unlock() + if queued { + wakeFinalizer() + } + return true +} + +func wakeFinalizer() { + if hasScheduler || hasParallelism { + gcLock.Lock() + spawn := !finalizerRunnerStarted + if spawn { + finalizerRunnerStarted = true + } + gcLock.Unlock() + if spawn { + spawnFinalizerRunner() + } + } else { + drainFinalizers() + } +} + +func drainFinalizers() { + if finalizerDraining { + return + } + finalizerDraining = true + for { + gcLock.Lock() + n := finalizerPending + if n != nil { + finalizerPending = n.next + } + gcLock.Unlock() + if n == nil { + break + } + callFinalizer(n.ptr, n.fn) + } + finalizerDraining = false +} + +func finalizerRunner() { + for { + drainFinalizers() + gcLock.Lock() + if finalizerPending == nil { + finalizerRunnerStarted = false + gcLock.Unlock() + return + } + gcLock.Unlock() + } +} + +//export tinygo_runtime_bdwgc_register_finalizer +func libgc_register_finalizer(uintptr, uintptr) uintptr + +//export GC_should_invoke_finalizers +func libgc_should_invoke_finalizers() int32 + +//export GC_invoke_finalizers +func libgc_invoke_finalizers() int32 + //export GC_init func libgc_init() diff --git a/src/runtime/gc_finalizer.go b/src/runtime/gc_finalizer.go index df3a4c6c6f..5d08a22e29 100644 --- a/src/runtime/gc_finalizer.go +++ b/src/runtime/gc_finalizer.go @@ -33,10 +33,6 @@ type finalizerEntry struct { fn interface{} } -// finalizerGCThreshold starts pressure GC when registrations indicate external memory pressure. -// Larger tables use a proportional threshold. Zero disables this trigger. -const finalizerGCThreshold = 32 - var ( finalizers *finalizerEntry // registered finalizers; a GC root that keeps fn values alive finalizerPending *finalizerEntry // finalizers whose object died, waiting to run @@ -55,20 +51,6 @@ var ( finalizerRunnerStarted bool ) -const finalizerGCDivisor = 2 - -// finalizerGCTrigger scales the threshold so scan work stays proportional to registrations. -// It uses finalizerGCThreshold as the minimum. -func finalizerGCTrigger() uintptr { - if finalizerGCThreshold == 0 { - return 0 - } - if proportional := numFinalizers / finalizerGCDivisor; proportional > finalizerGCThreshold { - return proportional - } - return finalizerGCThreshold -} - // finalizerBits records finalizer registrations by heap block for fast lookup. // Hold gcLock for every access because heap growth can replace the slice. var finalizerBits []byte @@ -328,18 +310,6 @@ func scanFinalizers() { } } -// 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() { @@ -397,7 +367,7 @@ func dequeueFinalizer() (*finalizerEntry, unsafe.Pointer) { // finalizerPressureGC runs a GC when registrations indicate external memory pressure. // It wakes the finalizer runner when the GC queues work. func finalizerPressureGC() bool { - trigger := finalizerGCTrigger() + trigger := finalizerGCTrigger(numFinalizers) if trigger == 0 || finalizersSinceGC < trigger { return false } diff --git a/src/runtime/gc_finalizer_common.go b/src/runtime/gc_finalizer_common.go new file mode 100644 index 0000000000..b78032fcf4 --- /dev/null +++ b/src/runtime/gc_finalizer_common.go @@ -0,0 +1,29 @@ +//go:build gc.conservative || gc.precise || gc.boehm + +package runtime + +import "unsafe" + +// finalizerGCThreshold starts pressure GC when registrations indicate external memory pressure. +// Larger tables use a proportional threshold. Zero disables this trigger. +const finalizerGCThreshold = 32 +const finalizerGCDivisor = 2 + +// finalizerGCTrigger scales the threshold so scan work stays proportional to registrations. +// It uses finalizerGCThreshold as the minimum. +func finalizerGCTrigger(count uintptr) uintptr { + if finalizerGCThreshold == 0 { + return 0 + } + if proportional := count / finalizerGCDivisor; proportional > finalizerGCThreshold { + return proportional + } + return finalizerGCThreshold +} + +// A func(*T) and a func(unsafe.Pointer) have the same TinyGo closure ABI. +func callFinalizer(objPtr unsafe.Pointer, fn interface{}) { + fnBox := (*_interface)(unsafe.Pointer(&fn)).value + f := *(*func(unsafe.Pointer))(fnBox) + f(objPtr) +} diff --git a/src/runtime/gc_finalizer_sched.go b/src/runtime/gc_finalizer_sched.go index 0a4783eb8c..f0d4c47391 100644 --- a/src/runtime/gc_finalizer_sched.go +++ b/src/runtime/gc_finalizer_sched.go @@ -1,4 +1,4 @@ -//go:build (gc.conservative || gc.precise) && (scheduler.tasks || scheduler.asyncify) +//go:build (gc.conservative || gc.precise || gc.boehm) && (scheduler.tasks || scheduler.asyncify) package runtime diff --git a/src/runtime/gc_finalizer_sched_none.go b/src/runtime/gc_finalizer_sched_none.go index 55d8a9a055..6333b70e4b 100644 --- a/src/runtime/gc_finalizer_sched_none.go +++ b/src/runtime/gc_finalizer_sched_none.go @@ -1,4 +1,4 @@ -//go:build (gc.conservative || gc.precise) && scheduler.none +//go:build (gc.conservative || gc.precise || gc.boehm) && scheduler.none package runtime diff --git a/src/runtime/gc_finalizer_sched_other.go b/src/runtime/gc_finalizer_sched_other.go index f874ec46b9..fdf56c0761 100644 --- a/src/runtime/gc_finalizer_sched_other.go +++ b/src/runtime/gc_finalizer_sched_other.go @@ -1,4 +1,4 @@ -//go:build (gc.conservative || gc.precise) && !scheduler.none && !scheduler.tasks && !scheduler.asyncify +//go:build (gc.conservative || gc.precise || gc.boehm) && !scheduler.none && !scheduler.tasks && !scheduler.asyncify package runtime diff --git a/testdata/finalizer.go b/testdata/finalizer.go index c15277785a..c8a9e6cc80 100644 --- a/testdata/finalizer.go +++ b/testdata/finalizer.go @@ -1,14 +1,5 @@ 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 } diff --git a/testdata/finalizerlarge.go b/testdata/finalizerlarge.go index 4ba8ddc04f..3e8d489887 100644 --- a/testdata/finalizerlarge.go +++ b/testdata/finalizerlarge.go @@ -1,24 +1,55 @@ package main -import "runtime" +import ( + "os" + "runtime" +) type largeFinalizerObject struct { data [128]byte } var largeFinalizerRan bool +var largeFinalizerSink int + +//go:noinline +func scrubLargeFinalizerStack(depth int) int { + if depth == 0 { + return largeFinalizerSink + } + var buf [64]int + for i := range buf { + buf[i] = depth + i + } + return scrubLargeFinalizerStack(depth-1) + buf[0] +} //go:noinline func registerLargeFinalizer() { p := new(largeFinalizerObject) + payload := new(largeFinalizerObject) + for i := range payload.data { + payload.data[i] = byte(i) + } runtime.SetFinalizer(p, func(*largeFinalizerObject) { + for i, value := range payload.data { + if value != byte(i) { + panic("finalizer closure data was collected") + } + } largeFinalizerRan = true }) } func main() { + if len(os.Args) > 1 && os.Args[1] == "graph" { + testFinalizerGraph() + println("ok") + return + } registerLargeFinalizer() for i := 0; i < 100 && !largeFinalizerRan; i++ { + largeFinalizerSink += scrubLargeFinalizerStack(40) runtime.GC() runtime.Gosched() } @@ -27,3 +58,53 @@ func main() { } println("ok") } + +type graphNode struct { + next *graphNode + tag uint32 + data [128]byte +} + +var ( + graphFinalized int + cycleFinalized int + graphKeepAlive []*graphNode +) + +//go:noinline +func registerGraphFinalizer() { + child := &graphNode{tag: 0x12345678} + parent := &graphNode{next: child} + runtime.SetFinalizer(parent, func(p *graphNode) { + for i := 0; i < 8192; i++ { + graphKeepAlive = append(graphKeepAlive, &graphNode{tag: uint32(i)}) + } + if p.next.tag != 0x12345678 { + panic("finalizer lost a referenced object") + } + graphFinalized++ + }) +} + +//go:noinline +func registerCycleFinalizers() { + first := new(graphNode) + second := new(graphNode) + first.next = second + second.next = first + runtime.SetFinalizer(first, func(*graphNode) { cycleFinalized++ }) + runtime.SetFinalizer(second, func(*graphNode) { cycleFinalized++ }) +} + +func testFinalizerGraph() { + registerGraphFinalizer() + registerCycleFinalizers() + for i := 0; i < 100 && (graphFinalized != 1 || cycleFinalized != 2); i++ { + largeFinalizerSink += scrubLargeFinalizerStack(40) + runtime.GC() + runtime.Gosched() + } + if graphFinalized != 1 || cycleFinalized != 2 { + panic("finalizers did not run for the graph and cycle") + } +} From 529a8aeb43e582f45568704c435e9e1e54f028b4 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:02:56 -0700 Subject: [PATCH 2/3] runtime: start Boehm finalizer runners only for queued work Registration can start an empty goroutine for every SetFinalizer call when the caller yields between registrations. Permanent runners avoid that churn but retain callback references on parked conservative stacks. Install the cooperative collection hook without starting a runner. Keep runners transient and create them only when callbacks are pending. --- main_test.go | 37 ++++++++++++++++++++- src/runtime/gc_boehm.go | 10 ++---- src/runtime/gc_finalizer_sched.go | 4 ++- src/runtime/gc_finalizer_sched_none.go | 2 ++ src/runtime/gc_finalizer_sched_other.go | 2 ++ testdata/finalizeridle.go | 33 +++++++++++++++++++ testdata/finalizerlarge.go | 43 ++++++++++++++++++++++++- 7 files changed, 121 insertions(+), 10 deletions(-) diff --git a/main_test.go b/main_test.go index 618c57ff17..7f315d0410 100644 --- a/main_test.go +++ b/main_test.go @@ -54,6 +54,32 @@ var supportedLinuxArches = map[string]string{ var sema = make(chan struct{}, runtime.NumCPU()) +func TestFinalizerRunnerRegistration(t *testing.T) { + t.Parallel() + for _, test := range []struct { + target, gc, scheduler string + }{ + {"wasm", "boehm", "asyncify"}, + {"wasm", "conservative", "asyncify"}, + {"", "boehm", "tasks"}, + {"", "boehm", "threads"}, + } { + if test.target == "" && runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + continue + } + t.Run(test.target+"/"+test.gc+"/"+test.scheduler, func(t *testing.T) { + t.Parallel() + options := optionsFromTarget(test.target, sema) + options.GC = test.gc + options.Scheduler = test.scheduler + options.GlobalValues = map[string]map[string]string{ + "main": {"finalizerIdleMode": "registration"}, + } + runTest("finalizeridle.go", options, t, nil, nil) + }) + } +} + func TestTrimPath(t *testing.T) { t.Setenv("CGO_CFLAGS", "-iquoteinclude -includestdint.h -imacros relative.h") root := t.TempDir() @@ -424,7 +450,11 @@ func TestBuild(t *testing.T) { options.GC = "boehm" t.Run("finalizerlarge.go-graph", func(t *testing.T) { t.Parallel() - runTest("finalizerlarge.go", compileopts.Options(options), t, []string{"graph"}, nil) + testOptions := compileopts.Options(options) + testOptions.GlobalValues = map[string]map[string]string{ + "main": {"finalizerLargeMode": "graph"}, + } + runTest("finalizerlarge.go", testOptions, t, nil, nil) }) for _, name := range []string{ "finalizer.go", @@ -437,6 +467,11 @@ func TestBuild(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() testOptions := compileopts.Options(options) + if name == "finalizerlarge.go" { + testOptions.GlobalValues = map[string]map[string]string{ + "main": {"finalizerLargeMode": "runner-payload"}, + } + } runTest(name, testOptions, t, nil, nil) }) } diff --git a/src/runtime/gc_boehm.go b/src/runtime/gc_boehm.go index ce8a69e3e2..0c9ab9303a 100644 --- a/src/runtime/gc_boehm.go +++ b/src/runtime/gc_boehm.go @@ -286,15 +286,11 @@ func SetFinalizer(obj interface{}, finalizer interface{}) { finalizersSinceGC-- } } - spawn := entry != nil && !hasParallelism && !finalizerRunnerStarted - if spawn { - finalizerRunnerStarted = true + if entry != nil { + initFinalizerScheduler() } gcResumeWorld() gcLock.Unlock() - if spawn { - spawnFinalizerRunner() - } KeepAlive(obj) } @@ -341,7 +337,7 @@ func finalizerPressureGC() bool { func wakeFinalizer() { if hasScheduler || hasParallelism { gcLock.Lock() - spawn := !finalizerRunnerStarted + spawn := finalizerPending != nil && !finalizerRunnerStarted if spawn { finalizerRunnerStarted = true } diff --git a/src/runtime/gc_finalizer_sched.go b/src/runtime/gc_finalizer_sched.go index f0d4c47391..a8e2a7867a 100644 --- a/src/runtime/gc_finalizer_sched.go +++ b/src/runtime/gc_finalizer_sched.go @@ -5,6 +5,8 @@ package runtime // Keep this setup in a file for these schedulers so unused finalizer code can be removed. // Cooperative schedulers also install the idle GC hook. func spawnFinalizerRunner() { - finalizerIdleGC = finalizerPressureGC + initFinalizerScheduler() go finalizerRunner() } + +func initFinalizerScheduler() { finalizerIdleGC = finalizerPressureGC } diff --git a/src/runtime/gc_finalizer_sched_none.go b/src/runtime/gc_finalizer_sched_none.go index 6333b70e4b..cf797cb05b 100644 --- a/src/runtime/gc_finalizer_sched_none.go +++ b/src/runtime/gc_finalizer_sched_none.go @@ -4,3 +4,5 @@ package runtime // scheduler.none has no goroutines; finalizers drain inline in wakeFinalizer. func spawnFinalizerRunner() {} + +func initFinalizerScheduler() {} diff --git a/src/runtime/gc_finalizer_sched_other.go b/src/runtime/gc_finalizer_sched_other.go index fdf56c0761..da08c8e07d 100644 --- a/src/runtime/gc_finalizer_sched_other.go +++ b/src/runtime/gc_finalizer_sched_other.go @@ -5,3 +5,5 @@ package runtime // spawnFinalizerRunner is the fallback for noncooperative schedulers. // These schedulers run finalizers but do not install the idle GC hook. func spawnFinalizerRunner() { go finalizerRunner() } + +func initFinalizerScheduler() {} diff --git a/testdata/finalizeridle.go b/testdata/finalizeridle.go index f533ec77bf..88cc9a465d 100644 --- a/testdata/finalizeridle.go +++ b/testdata/finalizeridle.go @@ -4,9 +4,11 @@ package main // The wasm target provides deterministic finalization for these tests. import ( + "os" "runtime" "sync/atomic" "time" + _ "unsafe" ) // batch must exceed the finalizer registration threshold to trigger idle collection. @@ -24,6 +26,32 @@ var ( type blockedObject struct{ x int } +var finalizerIdleMode string + +//go:linkname finalizerMallocs runtime.mallocs +func finalizerMallocs() uint64 + +func testRegistrationDoesNotSpawn() { + const registrations = 512 + live := make([]*[2]int, registrations) + before := finalizerMallocs() + for i := range live { + p := new([2]int) + live[i] = p + runtime.SetFinalizer(p, func(*[2]int) { panic("live object finalized") }) + runtime.Gosched() + } + allocations := finalizerMallocs() - before + if allocations > registrations*3 { + println("registration allocations:", allocations) + panic("empty finalizer runners were allocated") + } + for _, p := range live { + runtime.SetFinalizer(p, nil) + } + runtime.KeepAlive(live) +} + //go:noinline func blockOperation(kind int, ready chan<- struct{}, ch chan struct{}) { ready <- struct{}{} @@ -174,6 +202,11 @@ func testFinishedGoroutineArgs() { } func main() { + if finalizerIdleMode == "registration" || len(os.Args) > 1 && os.Args[1] == "registration" { + testRegistrationDoesNotSpawn() + println("ok") + return + } testPermanentlyBlockedStacks() testIdleCollect() testFinishedGoroutineStacks() diff --git a/testdata/finalizerlarge.go b/testdata/finalizerlarge.go index 3e8d489887..f5de454397 100644 --- a/testdata/finalizerlarge.go +++ b/testdata/finalizerlarge.go @@ -3,6 +3,8 @@ package main import ( "os" "runtime" + "sync/atomic" + "time" ) type largeFinalizerObject struct { @@ -11,6 +13,37 @@ type largeFinalizerObject struct { var largeFinalizerRan bool var largeFinalizerSink int +var runnerCallbackDone, runnerPayloadDone atomic.Uint32 +var finalizerLargeMode string + +//go:noinline +func registerRunnerPayload() { + payload := new(largeFinalizerObject) + payload.data[0] = 42 + runtime.SetFinalizer(payload, func(*largeFinalizerObject) { runnerPayloadDone.Add(1) }) + target := new(largeFinalizerObject) + runtime.SetFinalizer(target, func(*largeFinalizerObject) { + if payload.data[0] != 42 { + panic("finalizer lost its callback payload") + } + runnerCallbackDone.Add(1) + }) +} + +func testRunnerReleasesPayload() { + registerRunnerPayload() + for i := 0; i < 200 && (runnerCallbackDone.Load() != 1 || runnerPayloadDone.Load() != 1); i++ { + largeFinalizerSink += scrubLargeFinalizerStack(40) + runtime.GC() + runtime.Gosched() + if runtime.GOARCH != "wasm" { + time.Sleep(time.Millisecond) + } + } + if runnerCallbackDone.Load() != 1 || runnerPayloadDone.Load() != 1 { + panic("finalizer runner retained its callback payload") + } +} //go:noinline func scrubLargeFinalizerStack(depth int) int { @@ -21,6 +54,7 @@ func scrubLargeFinalizerStack(depth int) int { for i := range buf { buf[i] = depth + i } + largeFinalizerSink += buf[depth&63] return scrubLargeFinalizerStack(depth-1) + buf[0] } @@ -42,7 +76,11 @@ func registerLargeFinalizer() { } func main() { - if len(os.Args) > 1 && os.Args[1] == "graph" { + mode := finalizerLargeMode + if len(os.Args) > 1 { + mode = os.Args[1] + } + if mode == "graph" { testFinalizerGraph() println("ok") return @@ -56,6 +94,9 @@ func main() { if !largeFinalizerRan { panic("large object finalizer did not run") } + if mode == "runner-payload" { + testRunnerReleasesPayload() + } println("ok") } From fff5c9ef533577db46943bcb49d9a57d7931155e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:49:22 -0700 Subject: [PATCH 3/3] runtime: support interior Boehm finalizers Go permits finalizers on small pointer-free fields inside an allocation. Ignoring those registrations under Boehm makes collector behavior differ and can prevent associated resources from being released. Finalizers for distinct fields must remain independent, including when a registration at the allocation base is cleared. --- main_test.go | 56 +++++++++++++++ src/internal/reflectlite/type.go | 7 ++ src/runtime/gc_boehm.go | 74 +++++++++++++------ testdata/finalizer.go | 117 +++++++++++++++++++++++++++++-- 4 files changed, 227 insertions(+), 27 deletions(-) diff --git a/main_test.go b/main_test.go index 7f315d0410..6467271125 100644 --- a/main_test.go +++ b/main_test.go @@ -54,6 +54,62 @@ var supportedLinuxArches = map[string]string{ var sema = make(chan struct{}, runtime.NumCPU()) +func TestBoehmFinalizerInterior(t *testing.T) { + t.Parallel() + for _, test := range []struct { + target, scheduler string + }{ + {"wasm", "asyncify"}, + {"wasm", "none"}, + {"", "tasks"}, + {"", "threads"}, + {"", "none"}, + } { + if test.target == "" && runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + continue + } + t.Run(test.target+"/"+test.scheduler, func(t *testing.T) { + t.Parallel() + options := optionsFromTarget(test.target, sema) + options.GC = "boehm" + options.Scheduler = test.scheduler + options.Tags = append(options.Tags, "runtime_asserts") + options.GlobalValues = map[string]map[string]string{ + "main": {"finalizerTestMode": "interior"}, + } + runTest("finalizer.go", options, t, nil, nil) + }) + } +} + +func TestBoehmFinalizerInteriorInvalid(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("host finalizers need Linux or Darwin") + } + options := optionsFromTarget("", sema) + options.GC = "boehm" + config, err := builder.NewConfig(&options) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"interior-pointer", "interior-large"} { + t.Run(name, func(t *testing.T) { + output := &bytes.Buffer{} + _, err := buildAndRun("testdata/finalizer.go", config, output, []string{name}, nil, time.Minute, func(cmd *exec.Cmd, result builder.BuildResult) error { + cmd.Stdout = nil + cmd.Stderr = nil + data, err := cmd.CombinedOutput() + output.Write(data) + return err + }) + if err == nil || !strings.Contains(output.String(), "pointer not at beginning of allocated block") { + t.Fatalf("interior registration result: %v\n%s", err, output.String()) + } + }) + } +} + func TestFinalizerRunnerRegistration(t *testing.T) { t.Parallel() for _, test := range []struct { diff --git a/src/internal/reflectlite/type.go b/src/internal/reflectlite/type.go index d62e2f6e6d..ecbd4da911 100644 --- a/src/internal/reflectlite/type.go +++ b/src/internal/reflectlite/type.go @@ -171,6 +171,13 @@ type basicType struct { ptrTo *RawType } +// Go allows interior targets whose types could fit its tiny allocator. +// See runtime.SetFinalizer in https://go.dev/src/runtime/mfinal.go. +func FinalizerAllowsInterior(t *RawType) bool { + elem := t.elem() + return elem.Size() < 16 && elem.gcLayout() == gclayout.NoPtrs.AsPtr() +} + // All types that have an element type: named, chan, slice, array, map (but not // pointer because it doesn't have ptrTo). type elemType struct { diff --git a/src/runtime/gc_boehm.go b/src/runtime/gc_boehm.go index 0c9ab9303a..38c7def694 100644 --- a/src/runtime/gc_boehm.go +++ b/src/runtime/gc_boehm.go @@ -39,9 +39,10 @@ var ( ) type boehmFinalizer struct { - next *boehmFinalizer - fn interface{} - ptr unsafe.Pointer // keeps the object alive after Boehm dequeues its callback + next *boehmFinalizer + offset uintptr + fn interface{} + ptr unsafe.Pointer // keeps the object alive after Boehm dequeues its callback } var ( @@ -249,7 +250,8 @@ func setHeapEnd(newHeapEnd uintptr) { } func SetFinalizer(obj interface{}, finalizer interface{}) { - if reflectlite.ValueOf(obj).Kind() != reflectlite.Pointer { + value := reflectlite.ValueOf(obj) + if value.Kind() != reflectlite.Pointer { runtimeFatal("runtime.SetFinalizer: first argument is not a pointer") } if finalizer != nil && reflectlite.ValueOf(finalizer).Kind() != reflectlite.Func { @@ -265,26 +267,53 @@ func SetFinalizer(obj interface{}, finalizer interface{}) { entry = &boehmFinalizer{fn: finalizer} } gcLock.Lock() - // Only the base of a collectible allocation can be finalized. - if libgc_base(uintptr(objPtr)) != uintptr(objPtr) { + base := libgc_base(uintptr(objPtr)) + if base == 0 { gcLock.Unlock() return } - old := libgc_register_finalizer(uintptr(objPtr), uintptr(unsafe.Pointer(entry))) + if base != uintptr(objPtr) && !reflectlite.FinalizerAllowsInterior(value.RawType()) { + runtimeFatal("runtime.SetFinalizer: pointer not at beginning of allocated block") + } + offset := uintptr(objPtr) - base + old := libgc_register_finalizer(base, uintptr(unsafe.Pointer(entry))) if old == ^uintptr(0) { runtimeFatal("gc: cannot register finalizer") } - if old != 0 { - (*boehmFinalizer)(unsafe.Pointer(old)).fn = nil + head := (*boehmFinalizer)(unsafe.Pointer(old)) + replaced := false + var prev *boehmFinalizer + for n := head; n != nil; n = n.next { + if n.offset == offset { + if prev == nil { + head = n.next + } else { + prev.next = n.next + } + *n = boehmFinalizer{} + replaced = true + if entry == nil { + numFinalizers-- + if finalizersSinceGC != 0 { + finalizersSinceGC-- + } + } + break + } + prev = n } - if entry != nil && old == 0 { - numFinalizers++ - finalizersSinceGC++ - } else if entry == nil && old != 0 { - numFinalizers-- - if finalizersSinceGC != 0 { - finalizersSinceGC-- + if entry != nil { + entry.offset = offset + entry.next = head + if !replaced { + numFinalizers++ + finalizersSinceGC++ + } + } else if head != nil { + if libgc_register_finalizer(base, uintptr(unsafe.Pointer(head))) == ^uintptr(0) { + runtimeFatal("gc: cannot register finalizer") } + KeepAlive(head) } if entry != nil { initFinalizerScheduler() @@ -296,13 +325,14 @@ func SetFinalizer(obj interface{}, finalizer interface{}) { //export tinygo_runtime_bdwgc_finalizer func boehmQueueFinalizer(obj unsafe.Pointer, data unsafe.Pointer) { - n := (*boehmFinalizer)(data) - numFinalizers-- - if n.fn != nil { - n.ptr = obj + for n := (*boehmFinalizer)(data); n != nil; { + next := n.next + numFinalizers-- + n.ptr = unsafe.Add(obj, n.offset) n.next = finalizerPending finalizerPending = n finalizerQueued = true + n = next } } @@ -365,7 +395,9 @@ func drainFinalizers() { if n == nil { break } - callFinalizer(n.ptr, n.fn) + ptr, fn := n.ptr, n.fn + *n = boehmFinalizer{} + callFinalizer(ptr, fn) } finalizerDraining = false } diff --git a/testdata/finalizer.go b/testdata/finalizer.go index c8a9e6cc80..7a8d57c0ef 100644 --- a/testdata/finalizer.go +++ b/testdata/finalizer.go @@ -1,17 +1,25 @@ package main -import "runtime" +import ( + "os" + "runtime" + "sync/atomic" + "time" +) type T struct{ x int } var ( - ranCount int - clearedRan int - f1Ran int - f2Ran int - sink int + ranCount int + clearedRan int + f1Ran int + f2Ran int + sink int + interiorDone [4]atomic.Int32 ) +var finalizerTestMode string + // 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 @@ -111,7 +119,104 @@ func testReplace() { } } +type interiorObject struct { + pad [128]byte + value int + data [8]byte +} + +type interiorValue uint16 + +//go:noinline +func registerInteriorFinalizers() { + p := &interiorObject{value: 41} + p.data[1] = 42 + runtime.SetFinalizer(p, func(*interiorObject) { panic("cleared base finalizer ran") }) + runtime.SetFinalizer(&p.value, func(*int) { panic("cleared interior finalizer ran") }) + runtime.SetFinalizer(&p.data[1], func(v *byte) { + if *v != 42 { + panic("wrong interior byte finalizer argument") + } + interiorDone[2].Add(1) + }) + runtime.SetFinalizer(&p.data[2], func(*byte) { panic("cleared byte finalizer ran") }) + runtime.SetFinalizer(&p.value, nil) + runtime.SetFinalizer(&p.data[2], nil) + runtime.SetFinalizer(&p.data[3], nil) + runtime.SetFinalizer(p, nil) + runtime.SetFinalizer(&p.value, func(v *int) { + if *v != 41 { + panic("wrong interior int finalizer argument") + } + interiorDone[1].Add(1) + }) + runtime.SetFinalizer(p, func(v *interiorObject) { + if v.value != 41 { + panic("wrong base finalizer argument") + } + interiorDone[0].Add(1) + }) + q := new(struct { + pad [128]byte + value interiorValue + }) + q.value = 43 + runtime.SetFinalizer(&q.value, func(v *interiorValue) { + if *v != 43 { + panic("wrong named interior finalizer argument") + } + interiorDone[3].Add(1) + }) +} + +func testInteriorFinalizers() { + registerInteriorFinalizers() + for i := 0; i < 200; i++ { + sink += scrubStack(40) + runtime.GC() + runtime.Gosched() + if runtime.GOARCH != "wasm" { + time.Sleep(time.Millisecond) + } + done := true + for j := range interiorDone { + done = done && interiorDone[j].Load() == 1 + } + if done { + return + } + } + panic("interior finalizers did not run exactly once") +} + func main() { + if finalizerTestMode == "interior" { + testInteriorFinalizers() + println("ok") + return + } + if len(os.Args) > 1 { + switch os.Args[1] { + case "interior": + testInteriorFinalizers() + println("ok") + case "interior-pointer": + p := new(struct { + pad [128]byte + target *T + }) + runtime.SetFinalizer(&p.target, func(**T) {}) + runtime.KeepAlive(p) + case "interior-large": + p := new(struct { + pad [128]byte + target [16]byte + }) + runtime.SetFinalizer(&p.target, func(*[16]byte) {}) + runtime.KeepAlive(p) + } + return + } testFires() testClear() testReplace()