runtime: run syscall/js finalizers on wasm without a manual GC (#5545)

* runtime: run syscall/js finalizers on wasm without a manual GC

* runtime: address review feedback on finalizer idle GC

* runtime: clear a finished task's args pointer so its arguments are collectable

* runtime: skip the finalizer scan with a per-block registration bit

* runtime: guard the finalizer registration bitmap with gcLock

* testdata: cover finalizer invariants on every scheduler

* main_test: limit the finalizer scheduler variants to linux and darwin

* testdata: wait for the finalizer queue to drain before asserting

* testdata: make the finalizer counters atomic and wait for a known drain count

* runtime: add finalizer bookkeeping asserts under runtime_asserts

* runtime: address finalizer GC review feedback

* testdata: strengthen blocked stack finalizer test

* runtime: fix finalizer cleanup edge cases

* runtime: decouple wasm export scheduling from finalizers

* runtime: avoid redundant wakeups for re-entrant wasm exports

* runtime: simplify finalizer comments
This commit is contained in:
Felipe Gené
2026-08-26 15:03:44 -03:00
committed by GitHub
parent 8d6240a5e6
commit 31fff2c9a3
22 changed files with 1048 additions and 56 deletions
+177
View File
@@ -0,0 +1,177 @@
package main
// Test finalizer registration, replacement, removal, and reused heap addresses.
// The wasm target provides deterministic finalization for these tests.
import "runtime"
type box struct{ x int }
const batch = 32
var (
reregisteredRan int
replacedOldRan int
replacedNewRan int
churnRan int
reuseFirstRan int
reuseSecondRan int
keptRan int
droppedRan int
sink int
)
// scrubStack removes stale pointers from the helper frame so collection is deterministic.
// Call it at the same call depth as the allocation helper.
//
//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]
}
//go:noinline
func allocClearThenRegister() {
p := &box{x: 1}
runtime.SetFinalizer(p, func(*box) { panic("cleared finalizer ran") })
runtime.SetFinalizer(p, nil)
runtime.SetFinalizer(p, func(*box) { reregisteredRan++ })
}
func testClearThenRegister() {
allocClearThenRegister()
for i := 0; i < 200 && reregisteredRan == 0; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if reregisteredRan != 1 {
panic("finalizerbits: re-registered finalizer did not run exactly once")
}
}
//go:noinline
func allocRegisterTwice() {
for i := 0; i < batch; i++ {
p := &box{x: i}
runtime.SetFinalizer(p, func(*box) { replacedOldRan++ })
runtime.SetFinalizer(p, func(*box) { replacedNewRan++ })
}
}
func testRegisterTwiceLeavesOne() {
allocRegisterTwice()
for i := 0; i < 200 && replacedNewRan < batch; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if replacedOldRan != 0 {
panic("finalizerbits: replaced finalizer still ran")
}
if replacedNewRan != batch {
panic("finalizerbits: replacement did not run exactly once per object")
}
}
//go:noinline
func allocChurn() {
p := &box{x: 3}
for i := 0; i < 64; i++ {
runtime.SetFinalizer(p, func(*box) { churnRan++ })
runtime.SetFinalizer(p, nil)
}
}
func testChurnLeavesNothing() {
allocChurn()
for i := 0; i < 200; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if churnRan != 0 {
panic("finalizerbits: churned register/clear left a live registration")
}
}
//go:noinline
func allocFirstRound() {
for i := 0; i < batch; i++ {
p := &box{x: i}
runtime.SetFinalizer(p, func(*box) { reuseFirstRan++ })
}
}
//go:noinline
func allocSecondRound() {
for i := 0; i < batch; i++ {
p := &box{x: i}
runtime.SetFinalizer(p, func(*box) { reuseSecondRan++ })
}
}
func testAddressReuse() {
allocFirstRound()
for i := 0; i < 200 && reuseFirstRan < batch; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if reuseFirstRan != batch {
panic("finalizerbits: first round did not run every finalizer")
}
allocSecondRound()
for i := 0; i < 200 && reuseSecondRan < batch; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if reuseSecondRan != batch {
panic("finalizerbits: second round into reused memory lost finalizers")
}
}
//go:noinline
func allocMixedBatch() {
for i := 0; i < batch; i++ {
p := &box{x: i}
if i%2 == 0 {
runtime.SetFinalizer(p, func(*box) { droppedRan++ })
runtime.SetFinalizer(p, nil)
} else {
runtime.SetFinalizer(p, func(*box) { keptRan++ })
}
}
}
func testMixedBatch() {
allocMixedBatch()
for i := 0; i < 200 && keptRan < batch/2; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if droppedRan != 0 {
panic("finalizerbits: a cleared finalizer inside the batch ran")
}
if keptRan != batch/2 {
panic("finalizerbits: kept finalizers did not all run exactly once")
}
}
func main() {
testClearThenRegister()
testRegisterTwiceLeavesOne()
testChurnLeavesNothing()
testAddressReuse()
testMixedBatch()
println("ok")
}
+1
View File
@@ -0,0 +1 @@
ok
+182
View File
@@ -0,0 +1,182 @@
package main
// Test idle finalizer collection and the lifetime of blocked and completed asyncify stacks.
// The wasm target provides deterministic finalization for these tests.
import (
"runtime"
"sync/atomic"
"time"
)
// batch must exceed the finalizer registration threshold to trigger idle collection.
const batch = 64
var (
ranDropped int
ranOnStack int
ranInArgs int
sink int
blockedRan [3]atomic.Int32
controlRan atomic.Int32
)
type blockedObject struct{ x int }
//go:noinline
func blockOperation(kind int, ready chan<- struct{}, ch chan struct{}) {
ready <- struct{}{}
switch kind {
case 0:
select {}
case 1:
ch <- struct{}{}
case 2:
<-ch
}
}
//go:noinline
func holdWhileBlocked(kind int, ready chan<- struct{}, ch chan struct{}) {
p := &blockedObject{x: kind}
runtime.SetFinalizer(p, func(*blockedObject) { blockedRan[kind].Add(1) })
blockOperation(kind, ready, ch)
// blockOperation can return, so p remains live on this suspended stack.
runtime.KeepAlive(p)
}
//go:noinline
func dropProgressControl() {
p := &blockedObject{x: 8}
runtime.SetFinalizer(p, func(*blockedObject) { controlRan.Add(1) })
}
// testPermanentlyBlockedStacks checks that blocked task stacks remain GC roots.
// A control finalizer confirms that GC and finalizer processing made progress.
func testPermanentlyBlockedStacks() {
ready := make(chan struct{}, 3)
go holdWhileBlocked(0, ready, nil) // select{}
go holdWhileBlocked(1, ready, nil) // nil channel send
go holdWhileBlocked(2, ready, nil) // nil channel receive
<-ready
<-ready
<-ready
dropProgressControl()
for i := 0; i < 100 && controlRan.Load() == 0; i++ {
sink += scrubStack(40)
runtime.GC()
runtime.Gosched()
}
if controlRan.Load() != 1 {
panic("control finalizer did not prove GC progress")
}
// Collect once more so temporary scheduler roots cannot hide an unrooted
// blocked task during the control collection.
runtime.GC()
runtime.Gosched()
for i, name := range [...]string{"select{}", "nil-channel send", "nil-channel receive"} {
if blockedRan[i].Load() != 0 {
panic(name + " stack-held object was finalized")
}
}
}
// scrubStack removes stale pointers from the helper frame so collection is deterministic.
// Call it at the same call depth as the allocation helper.
//
//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]
}
// registerAndDrop creates unreachable objects with finalizers that do not capture them.
// This allows the idle GC to collect the objects.
//
//go:noinline
func registerAndDrop() {
for i := 0; i < batch; i++ {
p := new([2]int)
runtime.SetFinalizer(p, func(*[2]int) { ranDropped++ })
}
}
func testIdleCollect() {
registerAndDrop()
for i := 0; i < 500 && ranDropped < batch; i++ {
sink += scrubStack(40)
time.Sleep(time.Millisecond)
}
if ranDropped != batch {
panic("idle collection did not run every finalizer")
}
}
func testFinishedGoroutineStacks() {
done := make(chan struct{})
for i := 0; i < batch; i++ {
go func() {
p := new([2]int)
runtime.SetFinalizer(p, func(*[2]int) { ranOnStack++ })
// p stays on this goroutine's stack until it returns just below.
done <- struct{}{}
}()
}
for i := 0; i < batch; i++ {
<-done
}
for i := 0; i < 500 && ranOnStack < batch; i++ {
sink += scrubStack(40)
time.Sleep(time.Millisecond)
}
if ranOnStack != batch {
panic("finished goroutine stack still pinned finalized objects")
}
}
// launchArgGoroutine passes an object through the task argument bundle.
// The caller returns so scrubStack can remove its transient pointer.
//
//go:noinline
func launchArgGoroutine(done chan struct{}) {
p := new([2]int)
runtime.SetFinalizer(p, func(*[2]int) { ranInArgs++ })
go func(q *[2]int) {
sink += q[0]
done <- struct{}{}
}(p)
}
func testFinishedGoroutineArgs() {
done := make(chan struct{})
for i := 0; i < batch; i++ {
launchArgGoroutine(done)
}
for i := 0; i < batch; i++ {
<-done
}
for i := 0; i < 500 && ranInArgs < batch; i++ {
sink += scrubStack(40)
time.Sleep(time.Millisecond)
}
if ranInArgs != batch {
panic("finished goroutine args still pinned finalized objects")
}
}
func main() {
testPermanentlyBlockedStacks()
testIdleCollect()
testFinishedGoroutineStacks()
testFinishedGoroutineArgs()
println("ok")
}
+1
View File
@@ -0,0 +1 @@
ok
+87
View File
@@ -0,0 +1,87 @@
package main
// Test finalizer invariants that do not require unreachable objects to be collected.
// runtime_asserts checks the finalizer table, count, and bitmap.
import (
"runtime"
"sync/atomic"
)
type obj struct{ x int }
const batch = 8
// Finalizers may run concurrently with main under the threads and cores
// schedulers, so all observations shared with a finalizer are atomic.
var (
clearedRan atomic.Int32
replacedRan atomic.Int32
reachedRan atomic.Int32
ranTwice atomic.Int32
seen [batch]atomic.Int32
reachable []*obj
)
//go:noinline
func dropCleared() {
p := &obj{}
runtime.SetFinalizer(p, func(*obj) { clearedRan.Add(1) })
runtime.SetFinalizer(p, nil)
}
//go:noinline
func dropReplaced(id int) {
p := &obj{}
runtime.SetFinalizer(p, func(*obj) { replacedRan.Add(1) })
runtime.SetFinalizer(p, func(*obj) {
if seen[id].Add(1) > 1 {
ranTwice.Add(1)
}
})
}
//go:noinline
func keepReachable(id int) {
p := &obj{x: id}
runtime.SetFinalizer(p, func(*obj) { reachedRan.Add(1) })
reachable = append(reachable, p)
}
func main() {
for i := 0; i < batch; i++ {
dropCleared()
dropReplaced(i)
keepReachable(i)
}
// Run GC to check bookkeeping and give finalizers bounded opportunities to run.
// The checks do not require finalization of an unreachable object.
for i := 0; i < 8; i++ {
runtime.GC()
runtime.Gosched()
}
// Keep the reachable objects live across every collection above.
total := 0
for _, p := range reachable {
total += p.x
}
if total != batch*(batch-1)/2 {
println("FAIL: reachable set corrupted:", total)
return
}
switch {
case clearedRan.Load() != 0:
println("FAIL: cleared finalizer ran:", clearedRan.Load())
case replacedRan.Load() != 0:
println("FAIL: replaced finalizer ran:", replacedRan.Load())
case reachedRan.Load() != 0:
println("FAIL: reachable object was finalized:", reachedRan.Load())
case ranTwice.Load() != 0:
println("FAIL: finalizer ran more than once:", ranTwice.Load())
default:
println("ok")
}
}
+1
View File
@@ -0,0 +1 @@
ok
+29
View File
@@ -0,0 +1,29 @@
package main
import "runtime"
type largeFinalizerObject struct {
data [128]byte
}
var largeFinalizerRan bool
//go:noinline
func registerLargeFinalizer() {
p := new(largeFinalizerObject)
runtime.SetFinalizer(p, func(*largeFinalizerObject) {
largeFinalizerRan = true
})
}
func main() {
registerLargeFinalizer()
for i := 0; i < 100 && !largeFinalizerRan; i++ {
runtime.GC()
runtime.Gosched()
}
if !largeFinalizerRan {
panic("large object finalizer did not run")
}
println("ok")
}
+1
View File
@@ -0,0 +1 @@
ok
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"runtime"
"syscall/js"
)
//go:wasmimport tester finalizerRan
func finalizerRan()
//go:wasmimport tester backgroundRan
func backgroundRan()
//go:wasmimport tester callNestedExport
func callNestedExport()
var nestedCallback js.Func
//go:wasmexport launchBackground
func launchBackground() {
go func() {
backgroundRan()
}()
}
//go:wasmexport installNestedCallback
func installNestedCallback() {
nestedCallback = js.FuncOf(func(js.Value, []js.Value) any {
callNestedExport()
return nil
})
js.Global().Set("nestedExportCallback", nestedCallback)
}
//go:noinline
func registerFinalizersImpl() {
for i := 0; i < 32; i++ {
p := new([2]int)
runtime.SetFinalizer(p, func(*[2]int) { finalizerRan() })
}
}
//go:wasmexport registerFinalizers
func registerFinalizers() {
registerFinalizersImpl()
go func() {
backgroundRan()
}()
}
func main() {}
+74
View File
@@ -0,0 +1,74 @@
const fs = require('fs');
require('../targets/wasm_exec.js');
let finalized = 0;
let backgroundRuns = 0;
let instance;
const go = new Go();
const sleepTicks = go.importObject.gojs['runtime.sleepTicks'];
let zeroRearms = 0;
go.importObject.gojs['runtime.sleepTicks'] = timeout => {
if (Number(timeout) === 0) {
zeroRearms++;
}
return sleepTicks(timeout);
};
go.importObject.tester = {
finalizerRan: () => {
finalized++;
},
backgroundRan: () => {
backgroundRuns++;
},
callNestedExport: () => {
instance.exports.launchBackground();
},
};
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async result => {
instance = result.instance;
await go.run(instance);
const topLevelRearms = zeroRearms;
instance.exports.launchBackground();
if (backgroundRuns !== 0) {
throw new Error('wasm export ran a background goroutine before returning');
}
if (zeroRearms !== topLevelRearms + 1) {
throw new Error('top-level wasm export did not schedule exactly one wakeup');
}
for (let i = 0; i < 500 && backgroundRuns === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (backgroundRuns !== 1) {
throw new Error('wasm scheduler did not resume after an export without finalizers');
}
instance.exports.installNestedCallback();
const nestedRearms = zeroRearms;
global.nestedExportCallback();
if (zeroRearms !== nestedRearms) {
throw new Error('re-entrant wasm export scheduled a redundant wakeup');
}
if (backgroundRuns !== 2) {
throw new Error('outer scheduler did not drain work from a re-entrant wasm export');
}
instance.exports.registerFinalizers();
if (backgroundRuns !== 2) {
throw new Error('wasm export ran an unrelated goroutine before returning');
}
for (let i = 0; i < 500 && (finalized === 0 || backgroundRuns < 3); i++) {
await new Promise(resolve => setTimeout(resolve, 1));
}
if (finalized === 0) {
throw new Error('no wasm-export finalizer ran after returning to JavaScript');
}
if (backgroundRuns !== 3) {
throw new Error('wasm scheduler did not resume after a finalizer export');
}
}).catch(err => {
console.error(err);
process.exit(1);
});