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
+50 -1
View File
@@ -39,8 +39,13 @@ var (
runqueue task.Queue
sleepQueue *task.Task
sleepQueueBaseTime timeUnit
deadlockedTasks task.Queue
)
// finalizerIdleGC runs pressure GC at safe points and enables asyncify stack cleanup.
// The first finalizer installs it so unused code can be removed.
var finalizerIdleGC func() bool
// deadlock is called when a goroutine cannot proceed any more, but is in theory
// not exited (so deferred calls won't run). This can happen for example in code
// like this, that blocks forever:
@@ -49,12 +54,41 @@ var (
//
//go:noinline
func deadlock() {
// call yield without requesting a wakeup
// Keep permanently blocked tasks reachable so their suspended stacks remain
// GC roots, but never put them back on the runnable queue.
deadlockedTasks.Push(task.Current())
task.Pause()
runtimeFatal("unreachable")
}
// exitGoroutine ends an asyncify task that returned from its function.
// Unlike deadlock, this task will not resume.
func exitGoroutine() {
if finalizerIdleGC != nil {
task.MarkFinishing()
}
task.Pause()
runtimeFatal("unreachable")
}
// wasmExportExit stops the scheduler after a //go:wasmexport function returns.
// It is not used when the scheduler is disabled.
func wasmExportExit() {
schedulerExit = true
if finalizerIdleGC != nil {
task.MarkFinishing()
}
task.Pause()
// TODO: we could cache the allocated stack so we don't have to keep
// allocating a new stack on every //go:wasmexport call.
}
func goexit() {
if finalizerIdleGC != nil {
task.MarkFinishing()
}
task.Exit()
}
@@ -183,6 +217,11 @@ func scheduler(returnAtDeadlock bool) {
t := runqueue.Pop()
if t == nil {
// Run the pressure GC only when the scheduler is idle at the top level.
// This batches completed work and avoids collections during allocation.
if task.Current() == nil && finalizerIdleGC != nil && finalizerIdleGC() {
continue
}
if sleepQueue == nil && timerQueue == nil {
if returnAtDeadlock {
return
@@ -236,6 +275,16 @@ func scheduler(returnAtDeadlock bool) {
// //go:wasmexport function returned.
if GOARCH == "wasm" && schedulerExit {
schedulerExit = false // reset the signal
if task.Current() == nil {
if finalizerIdleGC != nil {
finalizerIdleGC()
}
// Return from an export at the top level before unrelated goroutines run.
// A nested export returns to its active outer scheduler.
if asyncScheduler && (!runqueue.Empty() || sleepQueue != nil || timerQueue != nil) {
sleepTicks(0)
}
}
return
}
}