From e6d065ae5caa583956486c713bf9da7874170d69 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 07:42:29 +0800 Subject: [PATCH 1/5] ssa: add explicit compiler GC root frames --- ssa/decl.go | 2 + ssa/gcroot.go | 202 ++++++++++++++++++++++++++++++++++++++++++++ ssa/gcroot_test.go | 145 +++++++++++++++++++++++++++++++ ssa/package.go | 1 + ssa/stmt_builder.go | 1 + 5 files changed, 351 insertions(+) create mode 100644 ssa/gcroot.go create mode 100644 ssa/gcroot_test.go diff --git a/ssa/decl.go b/ssa/decl.go index a575dd1bee..8f9d1f3a47 100644 --- a/ssa/decl.go +++ b/ssa/decl.go @@ -254,6 +254,8 @@ type aFunction struct { fakeUses []llvm.Value fakeUseSet map[llvm.Value]struct{} + gcRootPrev Expr + diFunc DIFunction } diff --git a/ssa/gcroot.go b/ssa/gcroot.go new file mode 100644 index 0000000000..011614a1d9 --- /dev/null +++ b/ssa/gcroot.go @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ssa + +import ( + "go/types" + + "github.com/xgo-dev/llvm" +) + +const gcRootChainName = "llvm_gc_root_chain" + +// EnableGCRoots controls compiler-maintained GC roots. +func (p Program) EnableGCRoots(enable bool) { + p.enableGCRoots = enable +} + +// GCRootsEnabled reports whether compiler-maintained GC roots are enabled. +func (p Program) GCRootsEnabled() bool { + return p.enableGCRoots +} + +// NewGCRoots reserves count pointer roots in one compiler-maintained frame. +// It must be called once, before the function emits a return. +func (p Function) NewGCRoots(count int) []Expr { + if count <= 0 { + return nil + } + if !p.gcRootPrev.IsNil() { + panic("ssa: GC roots already reserved") + } + b := p.NewBuilder() + defer b.Dispose() + entry := p.Block(0) + if entry.first.FirstInstruction().IsNil() { + b.SetBlockEx(entry, AtEnd, false) + } else { + b.SetBlockEx(entry, AtStart, false) + } + + prog := p.Prog + voidPtr := prog.tyVoidPtr() + rootArrayType := llvm.ArrayType(voidPtr, count) + frameType := prog.ctx.StructType([]llvm.Type{voidPtr, voidPtr, rootArrayType}, false) + frame := llvm.CreateAlloca(b.impl, frameType) + + chain := p.gcRootChain() + prev := llvm.CreateLoad(b.impl, voidPtr, chain) + b.impl.CreateStore(prev, llvm.CreateStructGEP(b.impl, frameType, frame, 0)) + + frameMap := p.newGCRootMap(count) + b.impl.CreateStore(frameMap, llvm.CreateStructGEP(b.impl, frameType, frame, 1)) + + roots := make([]Expr, count) + rootArray := llvm.CreateStructGEP(b.impl, frameType, frame, 2) + zero := llvm.ConstInt(prog.tyInt32(), 0, false) + for i := range roots { + index := llvm.ConstInt(prog.tyInt32(), uint64(i), false) + root := llvm.CreateInBoundsGEP(b.impl, rootArrayType, rootArray, []llvm.Value{zero, index}) + b.impl.CreateStore(llvm.ConstNull(voidPtr), root) + roots[i] = Expr{root, prog.Pointer(prog.VoidPtr())} + } + b.impl.CreateStore(frame, chain) + p.gcRootPrev = Expr{prev, prog.VoidPtr()} + return roots +} + +// SetGCRoot publishes value through a root created by NewGCRoots. +func (b Builder) SetGCRoot(root, value Expr) { + b.Store(root, b.Convert(b.Prog.VoidPtr(), value)) +} + +func (p Function) gcRootChain() llvm.Value { + global := p.Pkg.mod.NamedGlobal(gcRootChainName) + if global.IsNil() { + global = llvm.AddGlobal(p.Pkg.mod, p.Prog.tyVoidPtr(), gcRootChainName) + } + global.SetInitializer(llvm.ConstNull(p.Prog.tyVoidPtr())) + global.SetLinkage(llvm.LinkOnceAnyLinkage) + global.SetAlignment(p.Prog.PointerSize()) + return global +} + +func (p Function) newGCRootMap(count int) llvm.Value { + prog := p.Prog + mapType := prog.ctx.StructType([]llvm.Type{prog.tyInt32(), prog.tyInt32()}, false) + name := p.Name() + "$gcmap" + global := llvm.AddGlobal(p.Pkg.mod, mapType, name) + global.SetInitializer(llvm.ConstNamedStruct(mapType, []llvm.Value{ + llvm.ConstInt(prog.tyInt32(), uint64(count), false), + llvm.ConstInt(prog.tyInt32(), 0, false), + })) + global.SetGlobalConstant(true) + global.SetLinkage(llvm.InternalLinkage) + global.SetAlignment(4) + return global +} + +func (p Function) endGCRoots(b Builder) { + if p.gcRootPrev.IsNil() { + return + } + chain := p.gcRootChain() + for block := p.impl.FirstBasicBlock(); !block.IsNil(); block = llvm.NextBasicBlock(block) { + term := block.LastInstruction() + if term.IsNil() || term.InstructionOpcode() != llvm.Ret { + continue + } + b.impl.SetInsertPointBefore(term) + b.impl.CreateStore(p.gcRootPrev.impl, chain) + } +} + +// GCRootCount reports how many heap pointers typ contributes to a root frame. +func (p Program) GCRootCount(typ Type) int { + switch typ.kind { + case vkPtr, vkString, vkSlice, vkMap, vkEface, vkIface, vkClosure, vkChan: + return 1 + case vkStruct: + raw := typ.raw.Type.Underlying().(*types.Struct) + count := 0 + for i := 0; i < raw.NumFields(); i++ { + count += p.GCRootCount(p.Field(typ, i)) + } + return count + case vkArray: + raw := typ.raw.Type.Underlying().(*types.Array) + return int(raw.Len()) * p.GCRootCount(p.Index(typ)) + case vkTuple: + raw := typ.raw.Type.Underlying().(*types.Tuple) + count := 0 + for i := 0; i < raw.Len(); i++ { + count += p.GCRootCount(p.Field(typ, i)) + } + return count + default: + return 0 + } +} + +// GCRootPointers extracts the heap pointers represented by value. +func (b Builder) GCRootPointers(value Expr) []Expr { + var roots []Expr + b.appendGCRootPointers(&roots, value) + return roots +} + +func (b Builder) appendGCRootPointers(roots *[]Expr, value Expr) { + switch value.Type.kind { + case vkPtr, vkMap, vkChan: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), value)) + case vkString: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), b.StringData(value))) + case vkSlice: + *roots = append(*roots, b.Convert(b.Prog.VoidPtr(), b.SliceData(value))) + case vkEface, vkIface: + *roots = append(*roots, b.InterfaceData(value)) + case vkClosure: + data := llvm.CreateExtractValue(b.impl, value.impl, 1) + *roots = append(*roots, Expr{data, b.Prog.VoidPtr()}) + case vkStruct, vkTuple: + var count int + switch raw := value.Type.raw.Type.Underlying().(type) { + case *types.Struct: + count = raw.NumFields() + case *types.Tuple: + count = raw.Len() + } + for i := 0; i < count; i++ { + b.appendGCRootPointers(roots, b.Field(value, i)) + } + case vkArray: + raw := value.Type.raw.Type.Underlying().(*types.Array) + elem := b.Prog.Index(value.Type) + for i := 0; i < int(raw.Len()); i++ { + part := llvm.CreateExtractValue(b.impl, value.impl, i) + b.appendGCRootPointers(roots, Expr{part, elem}) + } + } +} + +// ClosureContextParam returns the hidden closure context parameter. +func (p Function) ClosureContextParam() Expr { + if p.base == 0 { + return Nil + } + return Expr{p.impl.Param(0), p.params[0]} +} diff --git a/ssa/gcroot_test.go b/ssa/gcroot_test.go new file mode 100644 index 0000000000..adaa8c3904 --- /dev/null +++ b/ssa/gcroot_test.go @@ -0,0 +1,145 @@ +//go:build !llgo + +package ssa_test + +import ( + "go/token" + "go/types" + "strings" + "testing" + + "github.com/goplus/llgo/ssa" + "github.com/goplus/llgo/ssa/ssatest" + "github.com/xgo-dev/llvm" +) + +func TestGCRootFrameIR(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + param := types.NewParam(token.NoPos, nil, "p", types.NewPointer(types.Typ[types.Int])) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.keep", sig, ssa.InGo) + b := fn.MakeBody(1) + mayGC := pkg.NewFunc("runtime.mayGC", ssa.NoArgsNoRet, ssa.InGo) + b.Call(mayGC.Expr) + root := fn.NewGCRoots(1)[0] + assertPanics(t, func() { + fn.NewGCRoots(1) + }) + b.SetGCRoot(root, b.Param(0)) + b.Return() + b.EndBuild() + + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + ir := pkg.String() + for _, want := range []string{ + `define void @main.keep(ptr %0)`, + `@llvm_gc_root_chain`, + `[1 x ptr]`, + `store ptr %0`, + } { + if !strings.Contains(ir, want) { + t.Fatalf("missing %q in GC root IR:\n%s", want, ir) + } + } + if strings.Contains(ir, `llvm.gcroot`) || strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("GC roots must be lowered before optimization:\n%s", ir) + } + if push, call := strings.Index(ir, `store ptr %`), strings.Index(ir, `call void @runtime.mayGC`); push < 0 || call < 0 || push > call { + t.Fatalf("GC root frame must be linked before a safepoint:\n%s", ir) + } + + mod := pkg.Module() + mod.SetDataLayout(prog.DataLayout()) + mod.SetTarget(prog.Target().Spec().Triple) + pbo := llvm.NewPassBuilderOptions() + defer pbo.Dispose() + if err := mod.RunPasses("default", prog.TargetMachine(), pbo); err != nil { + t.Fatalf("optimize GC root frame: %v", err) + } + if err := llvm.VerifyModule(mod, llvm.ReturnStatusAction); err != nil { + t.Fatalf("verify optimized GC root frame: %v", err) + } + optimized := mod.String() + if !strings.Contains(optimized, `@llvm_gc_root_chain`) || + strings.Contains(optimized, `llvm.gcroot`) || + strings.Contains(optimized, `gc "shadow-stack"`) { + t.Fatalf("optimization changed the lowered GC root ABI:\n%s", optimized) + } +} + +func TestGCRootReservationAndClosureContext(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + fn := pkg.NewFunc("main.empty", ssa.NoArgsNoRet, ssa.InGo) + if roots := fn.NewGCRoots(0); roots != nil { + t.Fatalf("NewGCRoots(0) = %v, want nil", roots) + } + if context := fn.ClosureContextParam(); !context.IsNil() { + t.Fatal("ordinary function unexpectedly has a closure context") + } + + context := types.NewParam(token.NoPos, nil, "__llgo_ctx", types.Typ[types.UnsafePointer]) + closureSig := ssa.FuncAddCtx(context, ssa.NoArgsNoRet) + closure := pkg.NewFuncEx("main.closure", closureSig, ssa.InGo, true, false) + if context := closure.ClosureContextParam(); context.IsNil() { + t.Fatal("closure function is missing its hidden context") + } +} + +func TestAggregateGCRootPointers(t *testing.T) { + prog := ssatest.NewProgram(t, &ssa.Target{GOOS: "js", GOARCH: "wasm"}) + pkg := prog.NewPackage("main", "main") + + ptr := types.NewPointer(types.Typ[types.Int]) + fnType := types.NewSignatureType(nil, nil, nil, nil, nil, false) + fields := []*types.Var{ + types.NewField(token.NoPos, nil, "p", ptr, false), + types.NewField(token.NoPos, nil, "s", types.NewSlice(types.Typ[types.Byte]), false), + types.NewField(token.NoPos, nil, "text", types.Typ[types.String], false), + types.NewField(token.NoPos, nil, "any", types.NewInterfaceType(nil, nil).Complete(), false), + types.NewField(token.NoPos, nil, "fn", fnType, false), + types.NewField(token.NoPos, nil, "array", types.NewArray(ptr, 2), false), + } + holder := types.NewStruct(fields, nil) + param := types.NewParam(token.NoPos, nil, "holder", holder) + sig := types.NewSignatureType(nil, nil, nil, types.NewTuple(param), nil, false) + fn := pkg.NewFunc("main.aggregate", sig, ssa.InGo) + b := fn.MakeBody(1) + + value := b.Param(0) + if got := prog.GCRootCount(value.Type); got != 7 { + t.Fatalf("GCRootCount(holder) = %d, want 7", got) + } + roots := b.GCRootPointers(value) + if len(roots) != 7 { + t.Fatalf("GCRootPointers(holder) returned %d roots, want 7", len(roots)) + } + slots := fn.NewGCRoots(len(roots)) + for i, value := range roots { + b.SetGCRoot(slots[i], value) + } + b.Return() + b.EndBuild() + + if err := llvm.VerifyModule(pkg.Module(), llvm.ReturnStatusAction); err != nil { + t.Fatal(err) + } + if ir := pkg.String(); !strings.Contains(ir, `[7 x ptr]`) { + t.Fatalf("aggregate did not emit one seven-root frame:\n%s", ir) + } +} + +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("operation did not panic") + } + }() + fn() +} diff --git a/ssa/package.go b/ssa/package.go index db41bae4ab..59c008db66 100644 --- a/ssa/package.go +++ b/ssa/package.go @@ -237,6 +237,7 @@ type aProgram struct { enableGoGlobalDCE bool enableDeadcodeDrop bool + enableGCRoots bool disableBoundsChecks bool pthreadStackSize uint64 enableLTOPluginMarker bool diff --git a/ssa/stmt_builder.go b/ssa/stmt_builder.go index e9dd136f63..e4936f8f13 100644 --- a/ssa/stmt_builder.go +++ b/ssa/stmt_builder.go @@ -77,6 +77,7 @@ func (b Builder) EndBuild() { b.Func.emitFakeUsesInlineAsm(b) } b.Func.endDefer(b) + b.Func.endGCRoots(b) } // Dispose disposes of the builder. From 474be1e5090fb4f7efd0cfec8e89e639d1e30097 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 07:42:40 +0800 Subject: [PATCH 2/5] cl: publish safepoint-live wasm GC roots --- cl/compile.go | 10 ++ cl/gcroot.go | 190 +++++++++++++++++++++++++++++++ cl/gcroot_internal_test.go | 153 +++++++++++++++++++++++++ cl/gcroot_test.go | 124 ++++++++++++++++++++ internal/build/build.go | 1 + internal/gcrootplan/plan.go | 175 ++++++++++++++++++++++++++++ internal/gcrootplan/plan_test.go | 136 ++++++++++++++++++++++ 7 files changed, 789 insertions(+) create mode 100644 cl/gcroot.go create mode 100644 cl/gcroot_internal_test.go create mode 100644 cl/gcroot_test.go create mode 100644 internal/gcrootplan/plan.go create mode 100644 internal/gcrootplan/plan_test.go diff --git a/cl/compile.go b/cl/compile.go index 1e8d1e07af..e30c8e91b5 100644 --- a/cl/compile.go +++ b/cl/compile.go @@ -179,6 +179,8 @@ type context struct { debugDIVars map[*types.Var]llssa.DIVar debugAllocVars map[*ssa.Alloc]*types.Var runtimeCallerFuncs map[*ssa.Function]bool + gcRoots map[ssa.Value][]llssa.Expr + gcClosureRoot llssa.Expr pcLineSeq uint64 patches Patches @@ -606,6 +608,7 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.inits = append(p.inits, func() { oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark := p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark oldLocalityFunction := p.locality.function + oldGCRoots, oldGCClosureRoot := p.gcRoots, p.gcClosureRoot p.fn = fn p.goFn = f p.callerFrameMark = llssa.Nil @@ -614,6 +617,7 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun defer func() { p.fn, p.goFn, p.methodNilDerefChecks, p.callerFrameMark = oldFn, oldGoFn, oldMethodNilDerefChecks, oldCallerFrameMark p.locality.function = oldLocalityFunction + p.gcRoots, p.gcClosureRoot = oldGCRoots, oldGCClosureRoot }() p.phis = nil if dbgSymsEnabled { @@ -634,6 +638,8 @@ func (p *context) compileFuncDecl(pkg llssa.Package, f *ssa.Function) (llssa.Fun p.prepareExportedLocalContext(f) p.bvals = make(map[ssa.Value]llssa.Expr) p.methodNilDerefChecks = collectMethodNilDerefChecks(f) + p.prepareGCRoots(f, hasCtx) + p.initGCRoots(b, f) off := make([]int, len(f.Blocks)) if isCgo { p.cgoArgs = make([]llssa.Expr, len(f.Params)) @@ -1178,6 +1184,7 @@ func (p *context) compilePhis(b llssa.Builder, block *ssa.BasicBlock) int { for i := 0; i < n; i++ { iv := block.Instrs[i].(*ssa.Phi) p.bvals[iv] = rets[i] + p.publishGCRoot(b, iv, rets[i]) } return n } @@ -1210,6 +1217,9 @@ func (p *context) compileInstrOrValue(b llssa.Builder, iv instrOrValue, asValue } log.Panicln("unreachable:", iv) } + defer func() { + p.publishGCRoot(b, iv, ret) + }() switch v := iv.(type) { case *ssa.Call: ret = p.call(b, llssa.Call, &v.Call) diff --git a/cl/gcroot.go b/cl/gcroot.go new file mode 100644 index 0000000000..587f20b943 --- /dev/null +++ b/cl/gcroot.go @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cl + +import ( + "go/token" + "go/types" + + "github.com/goplus/llgo/internal/gcrootplan" + llssa "github.com/goplus/llgo/ssa" + "golang.org/x/tools/go/ssa" +) + +func (p *context) prepareGCRoots(fn *ssa.Function, hasClosureContext bool) { + p.gcRoots = nil + p.gcClosureRoot = llssa.Nil + if !p.prog.GCRootsEnabled() { + return + } + + planned := gcrootplan.Plan(fn, func(value ssa.Value) bool { + switch value.(type) { + case *ssa.FreeVar: + return false + } + typ := p.type_(value.Type(), llssa.InGo) + return p.prog.GCRootCount(typ) != 0 + }, gcSafepoint) + counts := make(map[ssa.Value]int, len(planned)) + total := 0 + count := func(value ssa.Value) { + if _, ok := planned[value]; !ok { + return + } + typ := p.type_(value.Type(), llssa.InGo) + if n := p.prog.GCRootCount(typ); n != 0 { + counts[value] = n + total += n + } + } + for _, param := range fn.Params { + count(param) + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if value, ok := instr.(ssa.Value); ok { + count(value) + } + } + } + hasClosureRoot := hasClosureContext && functionHasGCSafepoint(fn) + if hasClosureRoot { + total++ + } + allSlots := p.fn.NewGCRoots(total) + next := 0 + roots := make(map[ssa.Value][]llssa.Expr, len(counts)) + assign := func(value ssa.Value) { + if n := counts[value]; n != 0 { + roots[value] = allSlots[next : next+n] + next += n + } + } + for _, param := range fn.Params { + assign(param) + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if value, ok := instr.(ssa.Value); ok { + assign(value) + } + } + } + p.gcRoots = roots + if hasClosureRoot { + p.gcClosureRoot = allSlots[next] + } +} + +func (p *context) initGCRoots(b llssa.Builder, fn *ssa.Function) { + if len(p.gcRoots) == 0 && p.gcClosureRoot.IsNil() { + return + } + b.SetBlockEx(p.fn.Block(0), llssa.AtEnd, true) + for i, param := range fn.Params { + if _, ok := p.gcRoots[param]; ok { + p.publishGCRoot(b, param, b.Param(i)) + } + } + if !p.gcClosureRoot.IsNil() { + b.SetGCRoot(p.gcClosureRoot, p.fn.ClosureContextParam()) + } +} + +func functionHasGCSafepoint(fn *ssa.Function) bool { + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if gcSafepoint(instr) { + return true + } + } + } + return false +} + +// gcSafepoint mirrors the operations whose LLGo lowering can call the runtime. +// Unknown instructions stay conservative. +func gcSafepoint(instr ssa.Instruction) bool { + switch instr := instr.(type) { + case *ssa.Phi, *ssa.DebugRef, *ssa.Extract, *ssa.Field, *ssa.FieldAddr, + *ssa.Index, *ssa.IndexAddr, *ssa.If, *ssa.Jump, *ssa.Return, + *ssa.Slice, *ssa.SliceToArrayPointer, *ssa.Store, *ssa.ChangeType: + return false + case *ssa.BinOp: + return gcBinOpSafepoint(instr) + case *ssa.UnOp: + return instr.Op == token.ARROW + case *ssa.Convert: + return gcConversionSafepoint(instr.X.Type(), instr.Type()) + case *ssa.Call: + if builtin, ok := instr.Call.Value.(*ssa.Builtin); ok { + switch builtin.Name() { + case "cap", "complex", "imag", "len", "real": + return false + } + } + return true + default: + return true + } +} + +func gcBinOpSafepoint(instr *ssa.BinOp) bool { + switch basicKind(instr.X.Type()) { + case types.String, types.UntypedString: + return true + } + _, isInterface := types.Unalias(instr.X.Type()).Underlying().(*types.Interface) + return isInterface +} + +func gcConversionSafepoint(src, dst types.Type) bool { + return isStringOrSlice(src) || isStringOrSlice(dst) +} + +func isStringOrSlice(typ types.Type) bool { + switch typ := types.Unalias(typ).Underlying().(type) { + case *types.Slice: + return true + case *types.Basic: + return typ.Info()&types.IsString != 0 + default: + return false + } +} + +func basicKind(typ types.Type) types.BasicKind { + if basic, ok := types.Unalias(typ).Underlying().(*types.Basic); ok { + return basic.Kind() + } + return types.Invalid +} + +func (p *context) publishGCRoot(b llssa.Builder, value ssa.Value, expr llssa.Expr) { + slots, ok := p.gcRoots[value] + if !ok || expr.IsNil() { + return + } + roots := b.GCRootPointers(expr) + if len(roots) != len(slots) { + panic("cl: inconsistent GC root layout") + } + for i, root := range roots { + b.SetGCRoot(slots[i], root) + } +} diff --git a/cl/gcroot_internal_test.go b/cl/gcroot_internal_test.go new file mode 100644 index 0000000000..fda4d0dd83 --- /dev/null +++ b/cl/gcroot_internal_test.go @@ -0,0 +1,153 @@ +//go:build !llgo + +package cl + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestGCSafepointClassification(t *testing.T) { + fn := buildGCRootSSAFunction(t, `package p +func helper() +func classify(p *int, text string, bytes []byte, ch chan int, m map[string]int, value any) { + _ = *p + _ = len(bytes) + _ = text + text + _ = string(bytes) + _ = value == value + helper() + _ = <-ch + m[text] = 1 +}`) + seen := make(map[string]bool) + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + switch instr := instr.(type) { + case *ssa.UnOp: + switch instr.Op { + case token.MUL: + seen["deref"] = true + if gcSafepoint(instr) { + t.Error("pointer dereference classified as a safepoint") + } + case token.ARROW: + seen["receive"] = true + if !gcSafepoint(instr) { + t.Error("channel receive not classified as a safepoint") + } + } + case *ssa.BinOp: + switch basicKind(instr.X.Type()) { + case types.String: + seen["string operation"] = true + if !gcSafepoint(instr) { + t.Error("string operation not classified as a safepoint") + } + default: + if _, ok := instr.X.Type().Underlying().(*types.Interface); ok { + seen["interface comparison"] = true + if !gcSafepoint(instr) { + t.Error("interface comparison not classified as a safepoint") + } + } + } + case *ssa.Convert: + seen["string conversion"] = true + if !gcSafepoint(instr) { + t.Error("string conversion not classified as a safepoint") + } + case *ssa.Call: + if builtin, ok := instr.Call.Value.(*ssa.Builtin); ok && builtin.Name() == "len" { + seen["pure builtin"] = true + if gcSafepoint(instr) { + t.Error("len classified as a safepoint") + } + } else { + seen["call"] = true + if !gcSafepoint(instr) { + t.Error("call not classified as a safepoint") + } + } + case *ssa.MapUpdate: + seen["map update"] = true + if !gcSafepoint(instr) { + t.Error("map update not classified as a safepoint") + } + } + } + } + for _, want := range []string{ + "deref", "receive", "string operation", "string conversion", + "interface comparison", "pure builtin", "call", "map update", + } { + if !seen[want] { + t.Errorf("%s instruction was not generated", want) + } + } + if !functionHasGCSafepoint(fn) { + t.Error("function with runtime operations has no GC safepoint") + } +} + +func TestGCSafepointPureInstructions(t *testing.T) { + for _, instr := range []ssa.Instruction{ + new(ssa.DebugRef), + new(ssa.Extract), + new(ssa.Field), + new(ssa.FieldAddr), + new(ssa.If), + new(ssa.Index), + new(ssa.IndexAddr), + new(ssa.Jump), + new(ssa.Phi), + new(ssa.Return), + new(ssa.Slice), + new(ssa.SliceToArrayPointer), + new(ssa.Store), + new(ssa.ChangeType), + } { + if gcSafepoint(instr) { + t.Errorf("%T classified as a safepoint", instr) + } + } + if !gcSafepoint(new(ssa.MakeSlice)) { + t.Error("unknown runtime-lowered instruction must stay conservative") + } + if gcConversionSafepoint(types.Typ[types.Int], types.Typ[types.Uint]) { + t.Error("numeric conversion classified as a safepoint") + } + pure := buildGCRootSSAFunction(t, `package p +func classify(p *int) *int { return p } +`) + if functionHasGCSafepoint(pure) { + t.Error("pure function has a GC safepoint") + } +} + +func buildGCRootSSAFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "gcroot.go", src, 0) + if err != nil { + t.Fatal(err) + } + pkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("gcroot", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return pkg.Func("classify") +} diff --git a/cl/gcroot_test.go b/cl/gcroot_test.go new file mode 100644 index 0000000000..9d222d862b --- /dev/null +++ b/cl/gcroot_test.go @@ -0,0 +1,124 @@ +//go:build !llgo + +package cl_test + +import ( + "strings" + "testing" + + "github.com/goplus/llgo/cl/cltest" + llssa "github.com/goplus/llgo/ssa" +) + +func TestCompileDirectGCRoots(t *testing.T) { + const src = `package main + +func use(*int) + +func keep(p *int) *int { + use(p) + return p +} + +func choose(cond bool, a, b *int) *int { + var p *int + if cond { + p = a + } else { + p = b + } + use(p) + return p +} +` + ir := cltest.CompileIREx(t, src, "gcroot.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `@llvm_gc_root_chain`) { + t.Fatalf("compiler-maintained root is missing:\n%s", ir) + } + if strings.Contains(ir, `llvm.gcroot`) || strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("compiler emitted roots that still require backend lowering:\n%s", ir) + } + if !strings.Contains(ir, `store ptr %0`) { + t.Fatalf("pointer parameter was not published:\n%s", ir) + } +} + +func TestCompileAggregateGCRoots(t *testing.T) { + const src = `package main + +type holder struct { + p *int + s []byte + text string + any any + fn func() + array [2]*int +} + +func useHolder(holder) + +func keep(h holder) holder { + useHolder(h) + return h +} +` + ir := cltest.CompileIREx(t, src, "gcroot_aggregate.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `[7 x ptr]`) { + t.Fatalf("aggregate did not emit one seven-root frame:\n%s", ir) + } +} + +func TestCompileGCRootsDisabled(t *testing.T) { + const src = `package main + +func keep(p *int) *int { return p } +` + ir := cltest.CompileIREx(t, src, "gcroot_disabled.go", false, nil) + if strings.Contains(ir, `llvm_gc_root_chain`) || strings.Contains(ir, `llvm.gcroot`) || + strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("disabled GC roots changed ordinary code:\n%s", ir) + } +} + +func TestCompileGCRootPlanning(t *testing.T) { + const pure = `package main +func keep(p *int) *int { return p } +` + ir := cltest.CompileIREx(t, pure, "gcroot_pure.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if strings.Contains(ir, `llvm_gc_root_chain`) || strings.Contains(ir, `llvm.gcroot`) || + strings.Contains(ir, `gc "shadow-stack"`) { + t.Fatalf("function without a safepoint emitted roots:\n%s", ir) + } + + const allocating = `package main +func keep(p *int, n int) *int { + _ = make([]byte, n) + return p +} +` + ir = cltest.CompileIREx(t, allocating, "gcroot_allocating.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `[1 x ptr]`) { + t.Fatalf("pointer live across allocation did not emit one root:\n%s", ir) + } + + const closure = `package main +func use(*int) +func keep(p *int) func() { + return func() { use(p) } +} +` + ir = cltest.CompileIREx(t, closure, "gcroot_closure.go", false, func(prog llssa.Program) { + prog.EnableGCRoots(true) + }) + if !strings.Contains(ir, `@llvm_gc_root_chain`) { + t.Fatalf("closure context live across a call was not rooted:\n%s", ir) + } +} diff --git a/internal/build/build.go b/internal/build/build.go index a245fccbe2..5a4071e102 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -457,6 +457,7 @@ func Build(inv Invocation) ([]Package, error) { } prog.EnableGoGlobalDCE(conf.goGlobalDCEEnabled()) prog.EnableDeadcodeDrop(conf.deadcodeDropEnabled()) + prog.EnableGCRoots(conf.Goarch == "wasm" && hasBuildTag(conf.Tags, "llgo_wasm_gc")) if conf.PthreadStackSize > 0 { prog.SetPthreadStackSize(uint64(conf.PthreadStackSize)) } diff --git a/internal/gcrootplan/plan.go b/internal/gcrootplan/plan.go new file mode 100644 index 0000000000..226cd77864 --- /dev/null +++ b/internal/gcrootplan/plan.go @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package gcrootplan computes the Go SSA values that must remain visible to a +// tracing collector while a function is stopped at a safepoint. +package gcrootplan + +import "golang.org/x/tools/go/ssa" + +// Plan returns values accepted by needsRoot that are live immediately before +// an instruction accepted by isSafepoint. +func Plan(fn *ssa.Function, needsRoot func(ssa.Value) bool, isSafepoint func(ssa.Instruction) bool) map[ssa.Value]struct{} { + if fn == nil || len(fn.Blocks) == 0 { + return nil + } + + blocks := make([]blockInfo, len(fn.Blocks)) + for _, block := range fn.Blocks { + info := &blocks[block.Index] + info.def = make(valueSet) + info.use = make(valueSet) + info.phiDef = make(valueSet) + info.edgeUse = make(map[int]valueSet) + + for _, instr := range block.Instrs { + if phi, ok := instr.(*ssa.Phi); ok { + info.def.add(phi) + info.phiDef.add(phi) + for i, pred := range block.Preds { + edge := info.edgeUse[pred.Index] + if edge == nil { + edge = make(valueSet) + info.edgeUse[pred.Index] = edge + } + addOperand(edge, phi.Edges[i]) + } + continue + } + for _, operand := range instr.Operands(nil) { + if operand != nil && *operand != nil { + value := *operand + if _, defined := info.def[value]; !defined { + addOperand(info.use, value) + } + } + } + if value, ok := instr.(ssa.Value); ok { + info.def.add(value) + } + } + } + + liveIn := make([]valueSet, len(blocks)) + liveOut := make([]valueSet, len(blocks)) + changed := true + for changed { + changed = false + for i := len(fn.Blocks) - 1; i >= 0; i-- { + block := fn.Blocks[i] + out := make(valueSet) + for _, succ := range block.Succs { + for value := range liveIn[succ.Index] { + if _, isPhi := blocks[succ.Index].phiDef[value]; !isPhi { + out.add(value) + } + } + for value := range blocks[succ.Index].edgeUse[block.Index] { + out.add(value) + } + } + in := out.clone() + in.removeAll(blocks[block.Index].def) + in.addAll(blocks[block.Index].use) + if !out.equal(liveOut[block.Index]) || !in.equal(liveIn[block.Index]) { + liveOut[block.Index] = out + liveIn[block.Index] = in + changed = true + } + } + } + + roots := make(map[ssa.Value]struct{}) + for _, block := range fn.Blocks { + live := liveOut[block.Index].clone() + for i := len(block.Instrs) - 1; i >= 0; i-- { + instr := block.Instrs[i] + if _, ok := instr.(*ssa.Phi); ok { + continue + } + if value, ok := instr.(ssa.Value); ok { + delete(live, value) + } + for _, operand := range instr.Operands(nil) { + if operand != nil && *operand != nil { + addOperand(live, *operand) + } + } + if isSafepoint(instr) { + for value := range live { + if needsRoot(value) { + roots[value] = struct{}{} + } + } + } + } + } + return roots +} + +type blockInfo struct { + def valueSet + use valueSet + phiDef valueSet + edgeUse map[int]valueSet +} + +type valueSet map[ssa.Value]struct{} + +func (s valueSet) add(value ssa.Value) { + if value != nil { + s[value] = struct{}{} + } +} + +func (s valueSet) addAll(other valueSet) { + for value := range other { + s.add(value) + } +} + +func (s valueSet) removeAll(other valueSet) { + for value := range other { + delete(s, value) + } +} + +func (s valueSet) clone() valueSet { + clone := make(valueSet, len(s)) + clone.addAll(s) + return clone +} + +func (s valueSet) equal(other valueSet) bool { + if len(s) != len(other) { + return false + } + for value := range s { + if _, ok := other[value]; !ok { + return false + } + } + return true +} + +func addOperand(set valueSet, value ssa.Value) { + switch value.(type) { + case nil, *ssa.Builtin, *ssa.Const, *ssa.Function, *ssa.Global: + return + default: + set.add(value) + } +} diff --git a/internal/gcrootplan/plan_test.go b/internal/gcrootplan/plan_test.go new file mode 100644 index 0000000000..8d6ef79e3c --- /dev/null +++ b/internal/gcrootplan/plan_test.go @@ -0,0 +1,136 @@ +package gcrootplan + +import ( + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "testing" + + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func TestPlanStraightLine(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(live, dead *int) *int { + _ = dead + keep(live) + return live +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "live") +} + +func TestPlanPhiEdges(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(cond bool, left, right *int) *int { + var value *int + if cond { + value = left + } else { + value = right + } + keep(value) + return value + }`) + roots := Plan(fn, pointerValue, isCall) + var foundPhi bool + for value := range roots { + if _, ok := value.(*ssa.Phi); ok { + foundPhi = true + } + } + if !foundPhi { + t.Fatal("merged pointer is not rooted at the call") + } +} + +func TestPlanPhiEdgeUse(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(cond bool, left, right *int) *int { + var value *int + if cond { + keep(nil) + value = left + } else { + keep(nil) + value = right + } + return value +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "left", "right") +} + +func TestPlanLoop(t *testing.T) { + fn := buildFunction(t, `package p +func keep(*int) +func f(head *int, n int) *int { + for n > 0 { + keep(head) + n-- + } + return head +}`) + roots := Plan(fn, pointerValue, isCall) + assertRootNames(t, roots, "head") +} + +func TestPlanNoSafepoint(t *testing.T) { + fn := buildFunction(t, `package p +func f(value *int) *int { return value }`) + if roots := Plan(fn, pointerValue, isCall); len(roots) != 0 { + t.Fatalf("Plan returned %d roots without a safepoint", len(roots)) + } +} + +func buildFunction(t *testing.T, src string) *ssa.Function { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "p.go", src, 0) + if err != nil { + t.Fatal(err) + } + ssaPkg, _, err := ssautil.BuildPackage( + &types.Config{Importer: importer.Default()}, + fset, + types.NewPackage("p", "p"), + []*ast.File{file}, + ssa.InstantiateGenerics, + ) + if err != nil { + t.Fatal(err) + } + return ssaPkg.Func("f") +} + +func pointerValue(value ssa.Value) bool { + _, ok := value.Type().Underlying().(*types.Pointer) + return ok +} + +func isCall(instr ssa.Instruction) bool { + _, ok := instr.(*ssa.Call) + return ok +} + +func assertRootNames(t *testing.T, roots map[ssa.Value]struct{}, names ...string) { + t.Helper() + for _, name := range names { + var found bool + for value := range roots { + if value.Name() == name { + found = true + break + } + } + if !found { + t.Errorf("root %q not found", name) + } + } +} From c93604f9539253550b1a04e5b50f156f886d4594 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 07:42:51 +0800 Subject: [PATCH 3/5] runtime/wasm: preserve roots across scheduler contexts --- runtime/internal/gcroot/current_stub.go | 7 + runtime/internal/gcroot/current_wasm.go | 8 + runtime/internal/gcroot/gcroot.go | 158 ++++++++++++++++++ runtime/internal/gcroot/gcroot_test.go | 130 ++++++++++++++ runtime/internal/runtime/proc_wasip1.go | 28 +++- runtime/internal/runtime/proc_wasm.go | 20 ++- runtime/internal/runtime/tinygogc/gc_wasm.go | 8 +- runtime/internal/runtime/wasm_gcroot.go | 33 ++++ runtime/internal/runtime/wasm_gcroot_stub.go | 17 ++ .../wasmcontext/_asm/context_wasm_gcroot.S | 9 + runtime/internal/wasmcontext/context_js.go | 4 - .../internal/wasmcontext/context_js_gcroot.go | 15 ++ .../wasmcontext/context_js_nogcroot.go | 13 ++ .../internal/wasmcontext/context_wasip1.go | 15 -- .../wasmcontext/context_wasip1_gcroot.go | 33 ++++ .../wasmcontext/context_wasip1_nogcroot.go | 20 +++ 16 files changed, 490 insertions(+), 28 deletions(-) create mode 100644 runtime/internal/gcroot/current_stub.go create mode 100644 runtime/internal/gcroot/current_wasm.go create mode 100644 runtime/internal/gcroot/gcroot.go create mode 100644 runtime/internal/gcroot/gcroot_test.go create mode 100644 runtime/internal/runtime/wasm_gcroot.go create mode 100644 runtime/internal/runtime/wasm_gcroot_stub.go create mode 100644 runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S create mode 100644 runtime/internal/wasmcontext/context_js_gcroot.go create mode 100644 runtime/internal/wasmcontext/context_js_nogcroot.go create mode 100644 runtime/internal/wasmcontext/context_wasip1_gcroot.go create mode 100644 runtime/internal/wasmcontext/context_wasip1_nogcroot.go diff --git a/runtime/internal/gcroot/current_stub.go b/runtime/internal/gcroot/current_stub.go new file mode 100644 index 0000000000..54b0ca040c --- /dev/null +++ b/runtime/internal/gcroot/current_stub.go @@ -0,0 +1,7 @@ +//go:build !llgo || !wasm || !llgo_wasm_gc + +package gcroot + +import "unsafe" + +var currentRootChain unsafe.Pointer diff --git a/runtime/internal/gcroot/current_wasm.go b/runtime/internal/gcroot/current_wasm.go new file mode 100644 index 0000000000..576994966a --- /dev/null +++ b/runtime/internal/gcroot/current_wasm.go @@ -0,0 +1,8 @@ +//go:build llgo && wasm && llgo_wasm_gc + +package gcroot + +import "unsafe" + +//go:linkname currentRootChain llvm_gc_root_chain +var currentRootChain unsafe.Pointer diff --git a/runtime/internal/gcroot/gcroot.go b/runtime/internal/gcroot/gcroot.go new file mode 100644 index 0000000000..c0bc40ecc6 --- /dev/null +++ b/runtime/internal/gcroot/gcroot.go @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package gcroot owns LLGo's per-G compiler root chains. +package gcroot + +import "unsafe" + +// Context stores one suspended execution owner's compiler root chain. +type Context struct { + next *Context + chain unsafe.Pointer +} + +type frameMap struct { + numRoots uint32 + numMeta uint32 +} + +type stackEntry struct { + next *stackEntry + m *frameMap +} + +var ( + contexts *Context + active *Context +) + +// CurrentChain returns the active execution owner's compiler root chain. +func CurrentChain() unsafe.Pointer { + return currentRootChain +} + +// RestoreChain installs a chain captured before a non-local control transfer. +func RestoreChain(chain unsafe.Pointer) { + currentRootChain = chain +} + +// Register adds a suspended context to root enumeration. +func Register(ctx *Context) { + if ctx == nil || registered(ctx) { + panic("gcroot: invalid context registration") + } + ctx.next = contexts + contexts = ctx +} + +// RegisterActive adds ctx and assigns the existing LLVM root chain to it. +func RegisterActive(ctx *Context) { + if active != nil { + panic("gcroot: active context already registered") + } + Register(ctx) + active = ctx +} + +// Switch saves the active chain and installs next's chain. +func Switch(next *Context) { + if next == nil { + panic("gcroot: switch to nil context") + } + SwitchAtBoundary(next) +} + +// SwitchAtBoundary saves the active chain and installs next's chain. +// +// This function is called between a context wrapper's root-frame setup and +// the target-specific stack switch. Keep it free of calls and allocations so +// it cannot acquire a compiler-maintained root frame of its own. +func SwitchAtBoundary(next *Context) { + if active == next { + return + } + if active != nil { + active.chain = currentRootChain + } + active = next + currentRootChain = next.chain +} + +// AdoptCurrent marks next active after a target-specific stack switch has +// already restored currentRootChain. +func AdoptCurrent(next *Context) { + active = next +} + +// Unregister removes a suspended context from root enumeration. +func Unregister(ctx *Context) { + if ctx == nil || ctx == active { + panic("gcroot: invalid context unregistration") + } + link := &contexts + for *link != nil && *link != ctx { + link = &(*link).next + } + if *link == nil { + panic("gcroot: context is not registered") + } + *link = ctx.next + ctx.next = nil + ctx.chain = nil +} + +// Visit calls visitor for every root slot in every registered context. +func Visit(visitor func(root *unsafe.Pointer, metadata unsafe.Pointer)) { + if visitor == nil { + return + } + for ctx := contexts; ctx != nil; ctx = ctx.next { + chain := ctx.chain + if ctx == active { + chain = currentRootChain + } + visitChain(chain, visitor) + } +} + +func registered(want *Context) bool { + for ctx := contexts; ctx != nil; ctx = ctx.next { + if ctx == want { + return true + } + } + return false +} + +func visitChain(chain unsafe.Pointer, visitor func(*unsafe.Pointer, unsafe.Pointer)) { + const pointerSize = unsafe.Sizeof(uintptr(0)) + for entry := (*stackEntry)(chain); entry != nil; entry = entry.next { + if entry.m == nil || entry.m.numMeta > entry.m.numRoots { + panic("gcroot: invalid compiler root frame") + } + roots := unsafe.Add(unsafe.Pointer(entry), unsafe.Sizeof(stackEntry{})) + metadata := unsafe.Add(unsafe.Pointer(entry.m), unsafe.Sizeof(frameMap{})) + for i := uint32(0); i < entry.m.numRoots; i++ { + var meta unsafe.Pointer + if i < entry.m.numMeta { + meta = *(*unsafe.Pointer)(unsafe.Add(metadata, uintptr(i)*pointerSize)) + } + root := (*unsafe.Pointer)(unsafe.Add(roots, uintptr(i)*pointerSize)) + visitor(root, meta) + } + } +} diff --git a/runtime/internal/gcroot/gcroot_test.go b/runtime/internal/gcroot/gcroot_test.go new file mode 100644 index 0000000000..44e30672c8 --- /dev/null +++ b/runtime/internal/gcroot/gcroot_test.go @@ -0,0 +1,130 @@ +package gcroot + +import ( + "testing" + "unsafe" +) + +type testFrameMap struct { + frameMap + meta [1]unsafe.Pointer +} + +type testStackEntry struct { + stackEntry + roots [2]unsafe.Pointer +} + +func TestVisitAndSwitchContexts(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + meta := unsafe.Pointer(uintptr(0x33)) + firstValue := unsafe.Pointer(uintptr(0x11)) + secondValue := unsafe.Pointer(uintptr(0x22)) + m := testFrameMap{ + frameMap: frameMap{numRoots: 2, numMeta: 1}, + meta: [1]unsafe.Pointer{meta}, + } + entry := testStackEntry{ + stackEntry: stackEntry{m: &m.frameMap}, + roots: [2]unsafe.Pointer{firstValue, secondValue}, + } + currentRootChain = unsafe.Pointer(&entry.stackEntry) + + var first, second Context + RegisterActive(&first) + Register(&second) + + var values, metadata []unsafe.Pointer + Visit(func(root *unsafe.Pointer, meta unsafe.Pointer) { + values = append(values, *root) + metadata = append(metadata, meta) + }) + if len(values) != 2 || values[0] != firstValue || values[1] != secondValue { + t.Fatalf("Visit values = %v, want [%p %p]", values, firstValue, secondValue) + } + if metadata[0] != meta || metadata[1] != nil { + t.Fatalf("Visit metadata = %v, want [%p nil]", metadata, meta) + } + + Switch(&second) + if first.chain != unsafe.Pointer(&entry.stackEntry) || currentRootChain != nil { + t.Fatal("Switch did not save the active chain and restore the next chain") + } + Unregister(&first) + if contexts != &second || second.next != nil { + t.Fatal("Unregister did not unlink the suspended context") + } +} + +func TestRejectsInvalidContextOperations(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + var ctx Context + assertPanics(t, func() { Register(nil) }) + RegisterActive(&ctx) + assertPanics(t, func() { Register(&ctx) }) + assertPanics(t, func() { RegisterActive(new(Context)) }) + assertPanics(t, func() { Switch(nil) }) + assertPanics(t, func() { Unregister(&ctx) }) +} + +func TestRejectsInvalidFrameMap(t *testing.T) { + m := frameMap{numRoots: 1, numMeta: 2} + entry := stackEntry{m: &m} + assertPanics(t, func() { + visitChain(unsafe.Pointer(&entry), func(*unsafe.Pointer, unsafe.Pointer) {}) + }) +} + +func TestRestoreChain(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + first := unsafe.Pointer(uintptr(0x11)) + second := unsafe.Pointer(uintptr(0x22)) + currentRootChain = first + if got := CurrentChain(); got != first { + t.Fatalf("CurrentChain() = %p, want %p", got, first) + } + RestoreChain(second) + if got := CurrentChain(); got != second { + t.Fatalf("CurrentChain() after restore = %p, want %p", got, second) + } +} + +func TestAdoptCurrent(t *testing.T) { + resetForTest() + t.Cleanup(resetForTest) + + var first, second Context + RegisterActive(&first) + Register(&second) + currentRootChain = unsafe.Pointer(uintptr(0x11)) + + AdoptCurrent(&second) + if active != &second { + t.Fatal("AdoptCurrent did not replace the active context") + } + if currentRootChain != unsafe.Pointer(uintptr(0x11)) { + t.Fatal("AdoptCurrent changed the chain restored by the stack switch") + } +} + +func assertPanics(t *testing.T, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Fatal("operation did not panic") + } + }() + fn() +} + +func resetForTest() { + contexts = nil + active = nil + currentRootChain = nil +} diff --git a/runtime/internal/runtime/proc_wasip1.go b/runtime/internal/runtime/proc_wasip1.go index cfa88861e4..6d91a5b041 100644 --- a/runtime/internal/runtime/proc_wasip1.go +++ b/runtime/internal/runtime/proc_wasip1.go @@ -27,6 +27,7 @@ import ( type runtimeContextPlatform struct { context wasmcontext.Context + gcRoot wasmGCRootContext runqNext *g runqQueued bool } @@ -39,8 +40,13 @@ var wasmSched struct { mainExited bool } +var wasmSystemGCRoot wasmGCRootContext + func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { gp := initG(ctx, callergp, status) + if wasmGCRootEnabled { + registerWasmGCRoot(&ctx.platform.gcRoot, false) + } if status == _Grunning { initWasmScheduler(gp) } @@ -53,6 +59,9 @@ func initWasmScheduler(gp *g) { return } wasmSched.started = true + if wasmGCRootEnabled { + registerWasmGCRoot(&wasmSystemGCRoot, true) + } mp := &wasmSched.m pp := &wasmSched.p mp.curg = gp @@ -112,7 +121,12 @@ func runWasmContext(gp *g) { pp.m = mp gp.m = mp setg(gp) - gp.context.platform.context.Resume() + gp.context.platform.context.Resume( + wasmGCRootPointer(&gp.context.platform.gcRoot), + ) + if wasmGCRootEnabled { + adoptWasmGCRoot(&wasmSystemGCRoot) + } } func releaseWasmOwnership(gp *g) { @@ -147,7 +161,11 @@ func releaseWasmContext(gp *g) { return } ctx := gp.context - ctx.platform.context.Close(FreeRoot) + platform := &ctx.platform + if wasmGCRootEnabled { + unregisterWasmGCRoot(&platform.gcRoot) + } + platform.context.Close(FreeRoot) freeRuntimeContext(ctx) } @@ -172,13 +190,13 @@ func goschedBackend() { fatal("runtime: invalid run queue insertion") return } - gp.context.platform.context.Suspend() + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) } func gopark() { gp := getg() casgstatus(gp, _Grunning, _Gwaiting) - gp.context.platform.context.Suspend() + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) } func goready(gp *g) { @@ -197,7 +215,7 @@ func goexitBackend(gp *g) { if gp.isMain { wasmSched.mainExited = true } - gp.context.platform.context.Suspend() + gp.context.platform.context.Suspend(wasmGCRootPointer(&wasmSystemGCRoot)) fatal("runtime: resumed dead WebAssembly goroutine") } diff --git a/runtime/internal/runtime/proc_wasm.go b/runtime/internal/runtime/proc_wasm.go index d0f07791d3..5c34ffa186 100644 --- a/runtime/internal/runtime/proc_wasm.go +++ b/runtime/internal/runtime/proc_wasm.go @@ -27,6 +27,7 @@ import ( type runtimeContextPlatform struct { context wasmcontext.Context + gcRoot wasmGCRootContext runqNext *g runqQueued bool } @@ -41,6 +42,9 @@ var wasmSched struct { func initRuntimeContext(ctx *runtimeContext, callergp *g, status uint32) *g { gp := initG(ctx, callergp, status) + if wasmGCRootEnabled { + registerWasmGCRoot(&ctx.platform.gcRoot, status == _Grunning) + } if status == _Grunning { initWasmScheduler(gp) } @@ -163,7 +167,10 @@ func resumeWasmG(old, next *g) { next.m = mp mp.curg = next setg(next) - old.context.platform.context.Swap(&next.context.platform.context) + old.context.platform.context.Swap( + &next.context.platform.context, + wasmGCRootPointer(&next.context.platform.gcRoot), + ) reapRetiredWasmG() } @@ -196,7 +203,10 @@ func resumeDeadWasmG(old, next *g) { next.m = mp mp.curg = next setg(next) - old.context.platform.context.Swap(&next.context.platform.context) + old.context.platform.context.Swap( + &next.context.platform.context, + wasmGCRootPointer(&next.context.platform.gcRoot), + ) fatal("runtime: resumed dead WebAssembly goroutine") } @@ -206,7 +216,11 @@ func reapRetiredWasmG() { return } wasmSched.retired = nil - ctx.platform.context.Close(FreeRoot) + platform := &ctx.platform + if wasmGCRootEnabled { + unregisterWasmGCRoot(&platform.gcRoot) + } + platform.context.Close(FreeRoot) freeRuntimeContext(ctx) } diff --git a/runtime/internal/runtime/tinygogc/gc_wasm.go b/runtime/internal/runtime/tinygogc/gc_wasm.go index c1b7e6300a..87f4ac25ac 100644 --- a/runtime/internal/runtime/tinygogc/gc_wasm.go +++ b/runtime/internal/runtime/tinygogc/gc_wasm.go @@ -3,9 +3,10 @@ package tinygogc import ( - _ "unsafe" + "unsafe" c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/gcroot" ) const LLGoFiles = "_wrap/gc_wasm.c" @@ -49,6 +50,11 @@ func gcMarkReachable() { if globalsStart < globalsEnd { markRoots(globalsStart, globalsEnd) } + gcroot.Visit(markWasmGCRoot) +} + +func markWasmGCRoot(root *unsafe.Pointer, _ unsafe.Pointer) { + markRoot(uintptr(unsafe.Pointer(root)), uintptr(*root)) } func gcStackStats() (inuse, sys uintptr) { diff --git a/runtime/internal/runtime/wasm_gcroot.go b/runtime/internal/runtime/wasm_gcroot.go new file mode 100644 index 0000000000..0b83b67862 --- /dev/null +++ b/runtime/internal/runtime/wasm_gcroot.go @@ -0,0 +1,33 @@ +//go:build llgo && wasm && llgo_wasm_gc + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +const wasmGCRootEnabled = true + +type wasmGCRootContext = gcroot.Context + +func registerWasmGCRoot(ctx *wasmGCRootContext, active bool) { + if active { + gcroot.RegisterActive(ctx) + } else { + gcroot.Register(ctx) + } +} + +func wasmGCRootPointer(ctx *wasmGCRootContext) unsafe.Pointer { + return unsafe.Pointer(ctx) +} + +func adoptWasmGCRoot(ctx *wasmGCRootContext) { + gcroot.AdoptCurrent(ctx) +} + +func unregisterWasmGCRoot(ctx *wasmGCRootContext) { + gcroot.Unregister(ctx) +} diff --git a/runtime/internal/runtime/wasm_gcroot_stub.go b/runtime/internal/runtime/wasm_gcroot_stub.go new file mode 100644 index 0000000000..701be4278e --- /dev/null +++ b/runtime/internal/runtime/wasm_gcroot_stub.go @@ -0,0 +1,17 @@ +//go:build llgo && wasm && !llgo_wasm_gc + +package runtime + +import "unsafe" + +const wasmGCRootEnabled = false + +type wasmGCRootContext struct{} + +func registerWasmGCRoot(*wasmGCRootContext, bool) {} + +func wasmGCRootPointer(*wasmGCRootContext) unsafe.Pointer { return nil } + +func adoptWasmGCRoot(*wasmGCRootContext) {} + +func unregisterWasmGCRoot(*wasmGCRootContext) {} diff --git a/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S b/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S new file mode 100644 index 0000000000..879aa36654 --- /dev/null +++ b/runtime/internal/wasmcontext/_asm/context_wasm_gcroot.S @@ -0,0 +1,9 @@ +.global __llgo_wasm_context_rewinding_state +.hidden __llgo_wasm_context_rewinding_state +.type __llgo_wasm_context_rewinding_state,@function +__llgo_wasm_context_rewinding_state: + .functype __llgo_wasm_context_rewinding_state () -> (i32) + i32.const 0 + i32.load8_u __llgo_wasm_context_rewinding + return + end_function diff --git a/runtime/internal/wasmcontext/context_js.go b/runtime/internal/wasmcontext/context_js.go index 56e267c420..32231d60b5 100644 --- a/runtime/internal/wasmcontext/context_js.go +++ b/runtime/internal/wasmcontext/context_js.go @@ -70,7 +70,3 @@ func (ctx *Context) Close(free func(unsafe.Pointer)) { freeStorage(ctx.stack, ctx.asyncifyStack, free) *ctx = Context{} } - -func (ctx *Context) Swap(next *Context) { - emscripten.FiberSwap(&ctx.fiber, &next.fiber) -} diff --git a/runtime/internal/wasmcontext/context_js_gcroot.go b/runtime/internal/wasmcontext/context_js_gcroot.go new file mode 100644 index 0000000000..245a03d726 --- /dev/null +++ b/runtime/internal/wasmcontext/context_js_gcroot.go @@ -0,0 +1,15 @@ +//go:build llgo && js && wasm && llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +func (ctx *Context) Swap(next *Context, nextRoots unsafe.Pointer) { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + emscripten.FiberSwap(&ctx.fiber, &next.fiber) +} diff --git a/runtime/internal/wasmcontext/context_js_nogcroot.go b/runtime/internal/wasmcontext/context_js_nogcroot.go new file mode 100644 index 0000000000..db05227638 --- /dev/null +++ b/runtime/internal/wasmcontext/context_js_nogcroot.go @@ -0,0 +1,13 @@ +//go:build llgo && js && wasm && !llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/clite/emscripten" +) + +func (ctx *Context) Swap(next *Context, _ unsafe.Pointer) { + emscripten.FiberSwap(&ctx.fiber, &next.fiber) +} diff --git a/runtime/internal/wasmcontext/context_wasip1.go b/runtime/internal/wasmcontext/context_wasip1.go index 837b42981b..44918eb603 100644 --- a/runtime/internal/wasmcontext/context_wasip1.go +++ b/runtime/internal/wasmcontext/context_wasip1.go @@ -63,19 +63,6 @@ func (ctx *Context) Close(free func(unsafe.Pointer)) { *ctx = Context{} } -func (ctx *Context) Resume() { - if !ctx.launched { - contextLaunch(ctx) - ctx.launched = true - return - } - contextRewind(ctx) -} - -func (ctx *Context) Suspend() { - contextUnwind(ctx) -} - //go:linkname contextLaunch C.__llgo_wasm_context_launch func contextLaunch(*Context) @@ -84,5 +71,3 @@ func contextRewind(*Context) //go:linkname contextUnwind C.__llgo_wasm_context_unwind func contextUnwind(*Context) - -const LLGoFiles = "_asm/context_wasm.S" diff --git a/runtime/internal/wasmcontext/context_wasip1_gcroot.go b/runtime/internal/wasmcontext/context_wasip1_gcroot.go new file mode 100644 index 0000000000..16d62ba4cf --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1_gcroot.go @@ -0,0 +1,33 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads && llgo_wasm_gc + +package wasmcontext + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +func (ctx *Context) Resume(nextRoots unsafe.Pointer) { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + if !ctx.launched { + contextLaunch(ctx) + ctx.launched = true + return + } + contextRewind(ctx) +} + +func (ctx *Context) Suspend(nextRoots unsafe.Pointer) { + // Asyncify replays this call stack while rewinding. The owner transition + // belongs only to the original unwind, not to that replay. + if contextRewinding() == 0 { + gcroot.SwitchAtBoundary((*gcroot.Context)(nextRoots)) + } + contextUnwind(ctx) +} + +//go:linkname contextRewinding C.__llgo_wasm_context_rewinding_state +func contextRewinding() uint32 + +const LLGoFiles = "_asm/context_wasm.S; _asm/context_wasm_gcroot.S" diff --git a/runtime/internal/wasmcontext/context_wasip1_nogcroot.go b/runtime/internal/wasmcontext/context_wasip1_nogcroot.go new file mode 100644 index 0000000000..5f6c6e76c8 --- /dev/null +++ b/runtime/internal/wasmcontext/context_wasip1_nogcroot.go @@ -0,0 +1,20 @@ +//go:build llgo && wasip1 && wasm && !llgo.wasi_threads && !llgo_wasm_gc + +package wasmcontext + +import "unsafe" + +func (ctx *Context) Resume(_ unsafe.Pointer) { + if !ctx.launched { + contextLaunch(ctx) + ctx.launched = true + return + } + contextRewind(ctx) +} + +func (ctx *Context) Suspend(_ unsafe.Pointer) { + contextUnwind(ctx) +} + +const LLGoFiles = "_asm/context_wasm.S" From 8e7016db5ba98f175e7db1ac598632517744a171 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 07:43:01 +0800 Subject: [PATCH 4/5] runtime/wasm: restore GC roots across panic unwinds --- .../internal/runtime/defer_gcroot_default.go | 18 ++++++++++ runtime/internal/runtime/defer_gcroot_wasm.go | 25 ++++++++++++++ runtime/internal/runtime/rethrow_default.go | 32 ++++++++++++++++++ runtime/internal/runtime/rethrow_wasm_gc.go | 33 +++++++++++++++++++ runtime/internal/runtime/z_default.go | 29 +--------------- runtime/internal/runtime/z_rt.go | 12 ------- ssa/eh.go | 3 ++ ssa/eh_defer_test.go | 25 ++++++++++++++ 8 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 runtime/internal/runtime/defer_gcroot_default.go create mode 100644 runtime/internal/runtime/defer_gcroot_wasm.go create mode 100644 runtime/internal/runtime/rethrow_default.go create mode 100644 runtime/internal/runtime/rethrow_wasm_gc.go diff --git a/runtime/internal/runtime/defer_gcroot_default.go b/runtime/internal/runtime/defer_gcroot_default.go new file mode 100644 index 0000000000..e451d8f381 --- /dev/null +++ b/runtime/internal/runtime/defer_gcroot_default.go @@ -0,0 +1,18 @@ +//go:build !wasm || !llgo_wasm_gc + +package runtime + +import "unsafe" + +// Defer presents defer statements in a function. +type Defer struct { + Addr unsafe.Pointer // sigjmpbuf + Bits uintptr + Link *Defer + Reth unsafe.Pointer // block address after Rethrow + Rund unsafe.Pointer // block address after RunDefers + Args unsafe.Pointer // defer func and args links +} + +// SetDeferGCRoot is omitted by the compiler when root publication is disabled. +func SetDeferGCRoot(*Defer) {} diff --git a/runtime/internal/runtime/defer_gcroot_wasm.go b/runtime/internal/runtime/defer_gcroot_wasm.go new file mode 100644 index 0000000000..0bf2eefe97 --- /dev/null +++ b/runtime/internal/runtime/defer_gcroot_wasm.go @@ -0,0 +1,25 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +import ( + "unsafe" + + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +// Defer presents defer statements in a function. +type Defer struct { + Addr unsafe.Pointer // sigjmpbuf + Bits uintptr + Link *Defer + Reth unsafe.Pointer // block address after Rethrow + Rund unsafe.Pointer // block address after RunDefers + Args unsafe.Pointer // defer func and args links + gcRoot unsafe.Pointer // root chain at the owning function's setjmp +} + +// SetDeferGCRoot records the chain that longjmp must restore. +func SetDeferGCRoot(frame *Defer) { + frame.gcRoot = gcroot.CurrentChain() +} diff --git a/runtime/internal/runtime/rethrow_default.go b/runtime/internal/runtime/rethrow_default.go new file mode 100644 index 0000000000..9f46735fc3 --- /dev/null +++ b/runtime/internal/runtime/rethrow_default.go @@ -0,0 +1,32 @@ +//go:build !baremetal && (!wasm || !llgo_wasm_gc) + +package runtime + +import ( + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/debug" +) + +// Rethrow rethrows a panic. +func Rethrow(link *Defer) { + gp := getg() + if ptr := gp.panic_; ptr != nil { + if link == nil { + TracePanic(*(*any)(ptr)) + if PanicTraceback == nil || !PanicTraceback(2) { + debug.PrintStack(2) + } + c.Free(ptr) + c.Exit(2) + } else { + c.Siglongjmp(link.Addr, 1) + } + } else if gp.goexit { + // Goexit runs deferred functions before the selected scheduler removes + // the current goroutine. + if link != nil { + c.Siglongjmp(link.Addr, 1) + } + goexitBackend(gp) + } +} diff --git a/runtime/internal/runtime/rethrow_wasm_gc.go b/runtime/internal/runtime/rethrow_wasm_gc.go new file mode 100644 index 0000000000..2120700239 --- /dev/null +++ b/runtime/internal/runtime/rethrow_wasm_gc.go @@ -0,0 +1,33 @@ +//go:build wasm && llgo_wasm_gc + +package runtime + +import ( + c "github.com/goplus/llgo/runtime/internal/clite" + "github.com/goplus/llgo/runtime/internal/clite/debug" + "github.com/goplus/llgo/runtime/internal/gcroot" +) + +// Rethrow rethrows a panic after discarding roots owned by skipped frames. +func Rethrow(link *Defer) { + gp := getg() + if ptr := gp.panic_; ptr != nil { + if link == nil { + TracePanic(*(*any)(ptr)) + if PanicTraceback == nil || !PanicTraceback(2) { + debug.PrintStack(2) + } + c.Free(ptr) + c.Exit(2) + } else { + gcroot.RestoreChain(link.gcRoot) + c.Siglongjmp(link.Addr, 1) + } + } else if gp.goexit { + if link != nil { + gcroot.RestoreChain(link.gcRoot) + c.Siglongjmp(link.Addr, 1) + } + goexitBackend(gp) + } +} diff --git a/runtime/internal/runtime/z_default.go b/runtime/internal/runtime/z_default.go index 71823fa552..284b2301d2 100644 --- a/runtime/internal/runtime/z_default.go +++ b/runtime/internal/runtime/z_default.go @@ -2,37 +2,10 @@ package runtime -import ( - c "github.com/goplus/llgo/runtime/internal/clite" - "github.com/goplus/llgo/runtime/internal/clite/debug" -) +import c "github.com/goplus/llgo/runtime/internal/clite" var ( printFormatPrefixInt = c.Str("%lld") printFormatPrefixUInt = c.Str("%llu") printFormatPrefixHex = c.Str("%llx") ) - -// Rethrow rethrows a panic. -func Rethrow(link *Defer) { - gp := getg() - if ptr := gp.panic_; ptr != nil { - if link == nil { - TracePanic(*(*any)(ptr)) - if PanicTraceback == nil || !PanicTraceback(2) { - debug.PrintStack(2) - } - c.Free(ptr) - c.Exit(2) - } else { - c.Siglongjmp(link.Addr, 1) - } - } else if gp.goexit { - // Goexit runs deferred functions before the selected scheduler removes - // the current goroutine. - if link != nil { - c.Siglongjmp(link.Addr, 1) - } - goexitBackend(gp) - } -} diff --git a/runtime/internal/runtime/z_rt.go b/runtime/internal/runtime/z_rt.go index 090e3e4cbd..bd6487db9d 100644 --- a/runtime/internal/runtime/z_rt.go +++ b/runtime/internal/runtime/z_rt.go @@ -23,18 +23,6 @@ import ( "github.com/goplus/llgo/runtime/internal/clite/setjmp" ) -// ----------------------------------------------------------------------------- - -// Defer presents defer statements in a function. -type Defer struct { - Addr unsafe.Pointer // sigjmpbuf - Bits uintptr - Link *Defer - Reth unsafe.Pointer // block address after Rethrow - Rund unsafe.Pointer // block address after RunDefers - Args unsafe.Pointer // defer func and args links -} - // Recover recovers a panic. func Recover() (ret any) { gp := getg() diff --git a/ssa/eh.go b/ssa/eh.go index 989371c75c..3cc2a2b787 100644 --- a/ssa/eh.go +++ b/ssa/eh.go @@ -270,6 +270,9 @@ func (b Builder) initDeferState(procBlk, rethrowBlk BasicBlock) (*aDefer, Expr, ptr := b.aggregateAllocU(prog.Defer(), jb.impl, zero.impl, link.impl, procBlk.Addr().impl) deferData := Expr{ptr, prog.DeferPtr()} b.Call(b.Pkg.rtFunc("SetThreadDefer"), deferData) + if prog.GCRootsEnabled() { + b.Call(b.Pkg.rtFunc("SetDeferGCRoot"), deferData) + } bitsPtr := b.FieldAddr(deferData, deferBits) rethPtr := b.FieldAddr(deferData, deferRethrow) rundPtr := b.FieldAddr(deferData, deferRunDefers) diff --git a/ssa/eh_defer_test.go b/ssa/eh_defer_test.go index 764134695c..c48b447033 100644 --- a/ssa/eh_defer_test.go +++ b/ssa/eh_defer_test.go @@ -40,6 +40,31 @@ func TestExplicitDeferStackIR(t *testing.T) { if !strings.Contains(ir, "sigsetjmp") && !strings.Contains(ir, "setjmp") { t.Fatalf("expected explicit defer stack setup in IR, got:\n%s", ir) } + if strings.Contains(ir, "SetDeferGCRoot") { + t.Fatalf("disabled root publication changed defer setup:\n%s", ir) + } +} + +func TestDeferCapturesGCRootChain(t *testing.T) { + prog := ssatest.NewProgram(t, nil) + prog.EnableGCRoots(true) + pkg := prog.NewPackage("foo", "foo") + + callee := pkg.NewFunc("callee", ssa.NoArgsNoRet, ssa.InGo) + cb := callee.MakeBody(1) + cb.Return() + cb.EndBuild() + + fn := pkg.NewFunc("main", ssa.NoArgsNoRet, ssa.InGo) + b := fn.MakeBody(1) + fn.SetRecover(fn.MakeBlock()) + b.Defer(ssa.DeferAlways, callee.Expr, ssa.Builder.Call) + b.Return() + b.EndBuild() + + if ir := pkg.Module().String(); !strings.Contains(ir, "SetDeferGCRoot") { + t.Fatalf("root-enabled defer did not capture its root chain:\n%s", ir) + } } func TestExplicitDeferStackFallbackAndNilBuiltin(t *testing.T) { From 5f96acb32daf6ae84812ca5f71ae343744f3acda Mon Sep 17 00:00:00 2001 From: Li Jie Date: Wed, 29 Jul 2026 07:43:10 +0800 Subject: [PATCH 5/5] test/wasm: cover suspended and recovered GC roots --- internal/build/testdata/wasm-gc/main.go | 92 +++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/internal/build/testdata/wasm-gc/main.go b/internal/build/testdata/wasm-gc/main.go index f66fdd51fb..eb7125b34e 100644 --- a/internal/build/testdata/wasm-gc/main.go +++ b/internal/build/testdata/wasm-gc/main.go @@ -17,6 +17,8 @@ func main() { panic("aligned allocation failed") } testRoots() + testSuspendedGRoots() + testRecoveredRootChain() testReclamation() testHeapGrowth() println("wasm gc ok") @@ -31,6 +33,96 @@ func testRoots() { globalRoot = nil } +type suspendedRoots struct { + p *payload + slice []byte + text string + any any + fn func() uint64 + array [2]*payload +} + +//go:noinline +func suspendedRootWorker(ready chan<- struct{}, resume <-chan struct{}, done chan<- struct{}) { + closurePayload := &payload{value: 0x11223344} + bytes := []byte{'w', 'a', 's', 'm'} + roots := suspendedRoots{ + p: &payload{value: 0x55667788}, + slice: []byte{1, 2, 3, 4}, + text: string(bytes), + any: &payload{value: 0x99aabbcc}, + fn: func() uint64 { return closurePayload.value }, + array: [2]*payload{{value: 0xddeeff00}, {value: 0x10203040}}, + } + ready <- struct{}{} + <-resume + + if roots.p.value != 0x55667788 || + len(roots.slice) != 4 || roots.slice[0] != 1 || roots.slice[3] != 4 || + roots.text != "wasm" || + roots.any.(*payload).value != 0x99aabbcc || + roots.fn() != 0x11223344 || + roots.array[0].value != 0xddeeff00 || roots.array[1].value != 0x10203040 { + panic("suspended goroutine roots were not retained") + } + done <- struct{}{} +} + +func testSuspendedGRoots() { + ready := make(chan struct{}) + resume := make(chan struct{}) + done := make(chan struct{}) + go suspendedRootWorker(ready, resume, done) + <-ready + + runtime.GC() + for i := 0; i < 4096; i++ { + garbage = &payload{value: uint64(i)} + } + garbage = nil + close(resume) + <-done +} + +//go:noinline +func usePayload(*payload) {} + +//go:noinline +func panicWithRoot(value *payload) { + usePayload(value) + panic("root-chain unwind") +} + +//go:noinline +func clobberStack(depth int, value uint64) uint64 { + var words [32]uint64 + for i := range words { + words[i] = value + uint64(i) + } + if depth != 0 { + return words[depth%len(words)] + clobberStack(depth-1, value+1) + } + return words[0] +} + +func testRecoveredRootChain() { + live := &payload{value: 0xabcdef01} + func() { + defer func() { + if recover() == nil { + panic("panic was not recovered") + } + }() + panicWithRoot(live) + }() + + _ = clobberStack(32, 1) + runtime.GC() + if live.value != 0xabcdef01 { + panic("root chain was not restored after recover") + } +} + //go:noinline func allocateGarbage() { for i := 0; i < 1024; i++ {