Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
851d47e
runtime: run syscall/js finalizers on wasm without a manual GC
felipegenef Jul 23, 2026
58898cd
runtime: address review feedback on finalizer idle GC
felipegenef Jul 23, 2026
ee59cd9
runtime: clear a finished task's args pointer so its arguments are co…
felipegenef Jul 24, 2026
d619b24
runtime: skip the finalizer scan with a per-block registration bit
felipegenef Jul 26, 2026
d456724
Merge remote-tracking branch 'upstream/dev' into fix-syscall-js-final…
felipegenef Jul 26, 2026
45ce61c
runtime: guard the finalizer registration bitmap with gcLock
felipegenef Aug 2, 2026
91d43ec
testdata: cover finalizer invariants on every scheduler
felipegenef Aug 2, 2026
363b901
main_test: limit the finalizer scheduler variants to linux and darwin
felipegenef Aug 2, 2026
69c945b
testdata: wait for the finalizer queue to drain before asserting
felipegenef Aug 3, 2026
37e7097
testdata: make the finalizer counters atomic and wait for a known dra…
felipegenef Aug 3, 2026
a6e58da
runtime: add finalizer bookkeeping asserts under runtime_asserts
felipegenef Aug 4, 2026
fc0fe94
runtime: address finalizer GC review feedback
felipegenef Aug 20, 2026
abb41c2
testdata: strengthen blocked stack finalizer test
felipegenef Aug 20, 2026
8a2f853
runtime: fix finalizer cleanup edge cases
felipegenef Aug 20, 2026
9c0cecc
runtime: decouple wasm export scheduling from finalizers
felipegenef Aug 24, 2026
e2af231
runtime: avoid redundant wakeups for re-entrant wasm exports
felipegenef Aug 25, 2026
214274a
runtime: simplify finalizer comments
felipegenef Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions compileopts/finalizer_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package compileopts

import (
"go/build/constraint"
"os"
"path/filepath"
"strings"
"testing"
)

// TestFinalizerRunnerSchedulerCoverage verifies that each scheduler selects one runner file.
// It uses validSchedulerOptions so new schedulers are included.
func TestFinalizerRunnerSchedulerCoverage(t *testing.T) {
files := []string{
"gc_finalizer_sched.go",
"gc_finalizer_sched_none.go",
"gc_finalizer_sched_other.go",
}
exprs := make([]constraint.Expr, len(files))
for i, name := range files {
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])
}
}
if len(matched) != 1 {
t.Errorf("scheduler.%s: spawnFinalizerRunner defined in %d files %v, want exactly 1",
sched, len(matched), matched)
}
}
}

func readBuildConstraint(t *testing.T, path string) constraint.Expr {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if constraint.IsGoBuild(line) {
expr, err := constraint.Parse(line)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
return expr
}
if line != "" && !strings.HasPrefix(line, "//") {
break // reached code before any //go:build line
}
}
t.Fatalf("%s: no //go:build line found", path)
return nil
}
11 changes: 5 additions & 6 deletions compiler/goroutine.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,10 +309,10 @@ func (c *compilerContext) createGoroutineStartWrapper(fnType llvm.Type, fn llvm.
}
defer b.Dispose()

var deadlock llvm.Value
var deadlockType llvm.Type
var exitGoroutine llvm.Value
var exitGoroutineType llvm.Type
if c.Scheduler == "asyncify" {
deadlockType, deadlock = c.getFunction(c.program.ImportedPackage("runtime").Members["deadlock"].(*ssa.Function))
exitGoroutineType, exitGoroutine = c.getFunction(c.program.ImportedPackage("runtime").Members["exitGoroutine"].(*ssa.Function))
}

if !fn.IsAFunction().IsNil() {
Expand Down Expand Up @@ -377,7 +377,7 @@ func (c *compilerContext) createGoroutineStartWrapper(fnType llvm.Type, fn llvm.
b.CreateCall(fnType, fn, params, "")

if c.Scheduler == "asyncify" {
b.CreateCall(deadlockType, deadlock, []llvm.Value{
b.CreateCall(exitGoroutineType, exitGoroutine, []llvm.Value{
llvm.Undef(c.dataPtrType),
}, "")
}
Expand Down Expand Up @@ -528,14 +528,13 @@ func (c *compilerContext) createGoroutineStartWrapper(fnType llvm.Type, fn llvm.
b.CreateCall(fnType, fnPtr, params, "")

if c.Scheduler == "asyncify" {
b.CreateCall(deadlockType, deadlock, []llvm.Value{
b.CreateCall(exitGoroutineType, exitGoroutine, []llvm.Value{
llvm.Undef(c.dataPtrType),
}, "")
}
}

if c.Scheduler == "asyncify" {
// The goroutine was terminated via deadlock.
b.CreateUnreachable()
} else {
// Finish the function. Every basic block must end in a terminator, and
Expand Down
12 changes: 6 additions & 6 deletions compiler/testdata/goroutine-wasm-asyncify.ll
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ entry:

declare void @main.regularFunction(i32, ptr) #0

declare void @runtime.deadlock(ptr) #0
declare void @runtime.exitGoroutine(ptr) #0

; Function Attrs: nounwind
define linkonce_odr void @"main.regularFunction$gowrapper"(ptr %0) unnamed_addr #2 {
entry:
%unpack.int = ptrtoint ptr %0 to i32
call void @main.regularFunction(i32 %unpack.int, ptr undef) #11
call void @runtime.deadlock(ptr undef) #11
call void @runtime.exitGoroutine(ptr undef) #11
unreachable
}

Expand All @@ -53,7 +53,7 @@ define linkonce_odr void @"main.inlineFunctionGoroutine$1$gowrapper"(ptr %0) unn
entry:
%unpack.int = ptrtoint ptr %0 to i32
call void @"main.inlineFunctionGoroutine$1"(i32 %unpack.int, ptr undef)
call void @runtime.deadlock(ptr undef) #11
call void @runtime.exitGoroutine(ptr undef) #11
unreachable
}

Expand Down Expand Up @@ -96,7 +96,7 @@ entry:
%2 = getelementptr inbounds nuw i8, ptr %0, i32 4
%3 = load ptr, ptr %2, align 4
call void @"main.closureFunctionGoroutine$1"(i32 %1, ptr %3)
call void @runtime.deadlock(ptr undef) #11
call void @runtime.exitGoroutine(ptr undef) #11
unreachable
}

Expand Down Expand Up @@ -130,7 +130,7 @@ entry:
%4 = getelementptr inbounds nuw i8, ptr %0, i32 8
%5 = load ptr, ptr %4, align 4
call void %5(i32 %1, ptr %3) #11
call void @runtime.deadlock(ptr undef) #11
call void @runtime.exitGoroutine(ptr undef) #11
unreachable
}

Expand Down Expand Up @@ -193,7 +193,7 @@ entry:
%6 = getelementptr inbounds nuw i8, ptr %0, i32 12
%7 = load ptr, ptr %6, align 4
call void @"interface:{Print:func:{basic:string}{}}.Print$invoke"(ptr %1, ptr %3, i32 %5, ptr %7, ptr undef) #11
call void @runtime.deadlock(ptr undef) #11
call void @runtime.exitGoroutine(ptr undef) #11
unreachable
}

Expand Down
4 changes: 2 additions & 2 deletions compiler/testdata/large.ll
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,13 @@ entry:
ret void
}

declare void @runtime.deadlock(ptr) #0
declare void @runtime.exitGoroutine(ptr) #0

; Function Attrs: nounwind
define linkonce_odr void @"main.readLargeValue$gowrapper"(ptr %0) unnamed_addr #6 {
entry:
%1 = call i8 @main.readLargeValue(ptr %0, ptr undef)
call void @runtime.deadlock(ptr undef) #9
call void @runtime.exitGoroutine(ptr undef) #9
unreachable
}

Expand Down
86 changes: 74 additions & 12 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ func TestBuild(t *testing.T) {
"channel.go",
"embed/",
"finalizer.go",
"finalizerbits.go",
"finalizeridle.go",
"finalizerinvariants.go",
"finalizerlarge.go",
"float.go",
"gc.go",
"generics.go",
Expand Down Expand Up @@ -116,7 +120,25 @@ func TestBuild(t *testing.T) {

t.Run("Host", func(t *testing.T) {
t.Parallel()
runPlatTests(optionsFromTarget("", sema), tests, t)
hostOptions := optionsFromTarget("", sema)
runPlatTests(hostOptions, tests, t)

// scheduler.threads needs threadID, which exists only on Linux and Darwin.
// scheduler.none does not link on Windows.
switch runtime.GOOS {
case "darwin", "linux":
for _, scheduler := range []string{"threads", "none"} {
scheduler := scheduler
t.Run("finalizerinvariants.go-gc-conservative-scheduler-"+scheduler, func(t *testing.T) {
t.Parallel()
options := compileopts.Options(hostOptions)
options.GC = "conservative"
options.Scheduler = scheduler
options.Tags = append(append([]string(nil), hostOptions.Tags...), "runtime_asserts")
runTest("finalizerinvariants.go", options, t, nil, nil)
})
}
}
})

// Test a few build options.
Expand Down Expand Up @@ -364,22 +386,38 @@ 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
if options.Target != "wasm" {
switch name {
case "finalizer.go", "finalizerbits.go", "finalizeridle.go", "finalizerlarge.go":
// These tests require deterministic finalization on target wasm.
// finalizerinvariants.go covers other block GC targets.
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":
// Skip because runtime.GC does not return. See the gc.go exclusion above.
continue
}
}

name := name // redefine to avoid race condition
t.Run(name, func(t *testing.T) {
t.Parallel()
runTest(name, options, t, nil, nil)
testOptions := compileopts.Options(options)
if name == "finalizerinvariants.go" || name == "finalizerlarge.go" {
testOptions.Tags = append(append([]string(nil), options.Tags...), "runtime_asserts")
}
runTest(name, testOptions, t, nil, nil)
})
}
if !strings.HasPrefix(spec.Emulator, "simavr ") {
Expand Down Expand Up @@ -920,6 +958,30 @@ func TestWasmExportJS(t *testing.T) {
}
}

func TestWasmExportFinalizersJS(t *testing.T) {
t.Parallel()

tmpdir := t.TempDir()
options := optionsFromTarget("wasm", sema)
options.BuildMode = "c-shared"
buildConfig, err := builder.NewConfig(&options)
if err != nil {
t.Fatal(err)
}
result, err := builder.Build("testdata/wasmexport-finalizer.go", ".wasm", tmpdir, buildConfig)
if err != nil {
t.Fatal("failed to build binary:", err)
}

output := &bytes.Buffer{}
cmd := exec.Command("node", "testdata/wasmexport-finalizer.js", result.Binary)
cmd.Stdout = output
cmd.Stderr = output
if err := cmd.Run(); err != nil {
t.Fatalf("failed to run node: %v\n%s", err, output)
}
}

// Test whether Go.run() (in wasm_exec.js) normally returns and returns the
// right exit code.
func TestWasmExit(t *testing.T) {
Expand Down
29 changes: 29 additions & 0 deletions src/internal/task/task_asyncify.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type state struct {
stackState

launched bool

// finishing marks a goroutine that paused after it completed.
// Resume uses this per task flag to clear the stack.
finishing bool
}

// stackState is the saved state of a stack while unwound.
Expand All @@ -42,6 +46,9 @@ type stackState struct {
// overwritten. It can be checked from time to time to see whether a stack
// overflow happened in the past.
canaryPtr *uintptr

// top marks the end of the stack buffer so it can be cleared after completion.
top unsafe.Pointer
}

// start creates and starts a new goroutine with the given function and arguments.
Expand Down Expand Up @@ -78,6 +85,22 @@ func (s *state) initialize(fn uintptr, args unsafe.Pointer, stackSize uintptr) {
// Calculate stack base addresses.
s.asyncifysp = unsafe.Add(stack, unsafe.Sizeof(uintptr(0)))
s.csp = unsafe.Add(stack, stackSize)
s.top = unsafe.Add(stack, stackSize)
}

//go:linkname memzero runtime.memzero
func memzero(ptr unsafe.Pointer, size uintptr)

// MarkFinishing marks the current goroutine for stack cleanup after it returns to the scheduler.
func MarkFinishing() {
currentTask.state.finishing = true
}

// clearStack removes stale pointers from a finished asyncify stack.
// The GC can then collect the stack and referenced objects.
func (t *Task) clearStack() {
base := unsafe.Pointer(t.state.canaryPtr)
memzero(base, uintptr(t.state.top)-uintptr(base))
}

// currentTask is the current running task, or nil if currently in the scheduler.
Expand Down Expand Up @@ -123,6 +146,12 @@ func (t *Task) Resume() {
if uintptr(t.state.asyncifysp) > uintptr(t.state.csp) {
runtimeFatal("stack overflow")
}
if t.state.finishing {
// The task is complete. Clear stale stack pointers and release its argument bundle.
t.state.finishing = false
t.clearStack()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might need t.state.args = nil?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. Added a testFinishedGoroutineArgs case in finalizeridle.go to cover it. Thanks!

t.state.args = nil
}
}

//go:linkname saveStackPointer runtime.saveStackPointer
Expand Down
6 changes: 6 additions & 0 deletions src/internal/task/task_finishing_tasks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build scheduler.tasks

package task

// MarkFinishing does nothing for scheduler.tasks because it does not use asyncify heap stacks.
func MarkFinishing() {}
Loading