diff --git a/cl/compile.go b/cl/compile.go index 1e8d1e07af..777affdc15 100644 --- a/cl/compile.go +++ b/cl/compile.go @@ -178,6 +178,8 @@ type context struct { anonDefers map[*ssa.Function]bool debugDIVars map[*types.Var]llssa.DIVar debugAllocVars map[*ssa.Alloc]*types.Var + stackClears map[ssa.Instruction][]*ssa.Alloc + finalizerPkgUses map[*ssa.Package]bool runtimeCallerFuncs map[*ssa.Function]bool pcLineSeq uint64 @@ -634,6 +636,11 @@ 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) + if p.enableConservativeLivenessClears(f) { + p.stackClears = p.collectStackClearPlans(f) + } else { + p.stackClears = nil + } off := make([]int, len(f.Blocks)) if isCgo { p.cgoArgs = make([]llssa.Expr, len(f.Params)) @@ -923,6 +930,10 @@ func (p *context) compileBlock(b llssa.Builder, block *ssa.BasicBlock, n int, do } else { p.compileInstr(b, instr) } + if isTerminatingInstruction(instr) { + continue + } + p.clearDeadAllocs(b, instr) } // is cgo cfunc but not return yet, some funcs has multiple blocks if (isCgoCfunc || isCgoC2 || isCgoCmacro) && !cgoReturned { @@ -1155,6 +1166,468 @@ func isAllocVargs(ctx *context, v *ssa.Alloc) bool { return false } +func (p *context) enableConservativeLivenessClears(fn *ssa.Function) bool { + if fn == nil || isCgoExternSymbol(fn) { + return false + } + pkg := declaredSSAPackage(fn) + if pkg == nil { + return false + } + return p.packageUsesRuntimeSetFinalizer(pkg) +} + +func (p *context) packageUsesRuntimeSetFinalizer(pkg *ssa.Package) bool { + if pkg == nil { + return false + } + if uses, ok := p.finalizerPkgUses[pkg]; ok { + return uses + } + if p.finalizerPkgUses == nil { + p.finalizerPkgUses = make(map[*ssa.Package]bool) + } + uses := false + seen := make(map[*ssa.Function]bool) + check := func(fn *ssa.Function) bool { + return p.functionUsesRuntimeSetFinalizer(fn, seen) + } + for _, member := range pkg.Members { + if fn, ok := member.(*ssa.Function); ok && check(fn) { + uses = true + break + } + } + if !uses && pkg.Prog != nil { + for _, member := range pkg.Members { + typ, ok := member.(*ssa.Type) + if !ok { + continue + } + for _, recv := range []types.Type{typ.Type(), types.NewPointer(typ.Type())} { + methods := pkg.Prog.MethodSets.MethodSet(recv) + for i := 0; i < methods.Len(); i++ { + obj, ok := methods.At(i).Obj().(*types.Func) + if !ok { + continue + } + if check(pkg.Prog.FuncValue(obj.Origin())) { + uses = true + break + } + } + if uses { + break + } + } + if uses { + break + } + } + } + p.finalizerPkgUses[pkg] = uses + return uses +} + +func declaredSSAPackage(fn *ssa.Function) *ssa.Package { + for fn != nil { + if fn.Pkg != nil { + return fn.Pkg + } + if origin := fn.Origin(); origin != nil && origin != fn { + fn = origin + continue + } + fn = fn.Parent() + } + return nil +} + +func (p *context) functionUsesRuntimeSetFinalizer(fn *ssa.Function, seen map[*ssa.Function]bool) bool { + if fn == nil || seen[fn] { + return false + } + seen[fn] = true + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + switch instr := instr.(type) { + case *ssa.Call: + if p.isRuntimeSetFinalizerCall(&instr.Call) { + return true + } + case *ssa.Defer: + if p.isRuntimeSetFinalizerCall(&instr.Call) { + return true + } + case *ssa.Go: + if p.isRuntimeSetFinalizerCall(&instr.Call) { + return true + } + } + } + } + for _, anon := range fn.AnonFuncs { + if p.functionUsesRuntimeSetFinalizer(anon, seen) { + return true + } + } + return false +} + +func hasConservativeGCPointers(t types.Type, seen map[types.Type]bool) bool { + if t == nil { + return false + } + t = types.Unalias(t) + if seen[t] { + return false + } + seen[t] = true + switch t := t.Underlying().(type) { + case *types.Pointer, *types.Slice, *types.Map, *types.Chan, *types.Signature, *types.Interface: + return true + case *types.Basic: + return t.Kind() == types.String || t.Kind() == types.UnsafePointer + case *types.Array: + return hasConservativeGCPointers(t.Elem(), seen) + case *types.Struct: + for i := 0; i < t.NumFields(); i++ { + if hasConservativeGCPointers(t.Field(i).Type(), seen) { + return true + } + } + } + return false +} + +func (p *context) shouldClearAlloc(v *ssa.Alloc) bool { + if v == nil || v.Heap || v.Comment == "varargs" || v.Comment == "makeslice" { + return false + } + ptr, ok := v.Type().Underlying().(*types.Pointer) + return ok && hasConservativeGCPointers(ptr.Elem(), map[types.Type]bool{}) +} + +func cyclicBlocks(blocks []*ssa.BasicBlock) map[*ssa.BasicBlock]bool { + // Compute strongly connected components once per function so liveness + // candidates do not repeat reachability walks over the same CFG. + cyclic := make(map[*ssa.BasicBlock]bool) + indices := make(map[*ssa.BasicBlock]int, len(blocks)) + lowlinks := make(map[*ssa.BasicBlock]int, len(blocks)) + onStack := make(map[*ssa.BasicBlock]bool, len(blocks)) + stack := make([]*ssa.BasicBlock, 0, len(blocks)) + nextIndex := 1 + + var visit func(*ssa.BasicBlock) + visit = func(block *ssa.BasicBlock) { + if block == nil { + return + } + index := nextIndex + nextIndex++ + indices[block] = index + lowlinks[block] = index + stack = append(stack, block) + onStack[block] = true + + for _, succ := range block.Succs { + if succ == nil { + continue + } + if indices[succ] == 0 { + visit(succ) + lowlinks[block] = min(lowlinks[block], lowlinks[succ]) + } else if onStack[succ] { + lowlinks[block] = min(lowlinks[block], indices[succ]) + } + } + if lowlinks[block] != index { + return + } + + var component []*ssa.BasicBlock + for { + last := len(stack) - 1 + member := stack[last] + stack = stack[:last] + onStack[member] = false + component = append(component, member) + if member == block { + break + } + } + if len(component) > 1 { + for _, member := range component { + cyclic[member] = true + } + return + } + for _, succ := range block.Succs { + if succ == block { + cyclic[block] = true + return + } + } + } + + for _, block := range blocks { + if block != nil && indices[block] == 0 { + visit(block) + } + } + return cyclic +} + +type instructionOperandScratch struct { + inline [8]*ssa.Value + operands []*ssa.Value +} + +type stackLivenessState struct { + value ssa.Value + slotAddress bool +} + +func (s *instructionOperandScratch) uses(instr ssa.Instruction, v ssa.Value) bool { + if instr == nil || v == nil { + return false + } + if s.operands == nil { + s.operands = s.inline[:0] + } else { + s.operands = s.operands[:0] + } + // Referrer lists are mutable in x/tools. Re-scan operands deliberately so + // stale entries that no longer name v make the liveness proof fail closed. + s.operands = instr.Operands(s.operands) + for _, operand := range s.operands { + if operand != nil && *operand == v { + return true + } + } + return false +} + +func instructionUsesValue(instr ssa.Instruction, v ssa.Value) bool { + var scratch instructionOperandScratch + return scratch.uses(instr, v) +} + +func instructionRetainsAddress(instr ssa.Instruction, v ssa.Value) bool { + // Side-effecting instructions can hide a stack address from the SSA + // referrer graph, so the liveness walk cannot follow later aliases. + // + // This switch deliberately defaults to retaining: every known instruction + // must either identify its non-retaining destination operand below or be a + // pure value instruction whose uses the recursive walk can follow. A new + // SSA instruction therefore fails closed until it is classified here. + switch instr := instr.(type) { + case *ssa.Store: + if instr.Val == v { + return true + } + return instr.Addr != v + case *ssa.MapUpdate: + if instr.Key == v || instr.Value == v { + return true + } + return instr.Map != v + case *ssa.Send: + if instr.X == v { + return true + } + return instr.Chan != v + case *ssa.Call: + // Calls may retain any operand, including invoke receivers. + return true + case *ssa.Select: + channelOperand := false + for _, state := range instr.States { + if state.Dir == types.SendOnly && state.Send == v { + return true + } + if state.Chan == v { + channelOperand = true + } + } + return !channelOperand + case *ssa.Alloc, *ssa.BinOp, *ssa.UnOp, *ssa.ChangeType, + *ssa.Convert, *ssa.MultiConvert, *ssa.ChangeInterface, + *ssa.SliceToArrayPointer, *ssa.MakeInterface, *ssa.MakeMap, + *ssa.MakeChan, *ssa.MakeSlice, *ssa.Slice, *ssa.FieldAddr, + *ssa.Field, *ssa.IndexAddr, *ssa.Index, *ssa.Lookup, *ssa.Range, + *ssa.Next, *ssa.TypeAssert, *ssa.Extract: + // These instructions only produce values. Recursively walking the + // result's referrers preserves address provenance until a load. + return false + } + return true +} + +func isTerminatingInstruction(instr ssa.Instruction) bool { + switch instr.(type) { + case *ssa.Jump, *ssa.Return, *ssa.If, *ssa.Panic: + return true + } + return false +} + +func (p *context) isRuntimeSetFinalizerCall(call *ssa.CallCommon) bool { + if call == nil { + return false + } + fn, ok := call.Value.(*ssa.Function) + if !ok || fn == nil || fn.Pkg == nil || fn.Pkg.Pkg == nil { + return false + } + if fn.Name() != "SetFinalizer" { + return false + } + switch fn.Pkg.Pkg.Path() { + case "runtime", "github.com/goplus/llgo/runtime/internal/lib/runtime": + return true + default: + return false + } +} + +func (p *context) lastUseInBlock(v ssa.Value, blk *ssa.BasicBlock, order map[ssa.Instruction]int) (ssa.Instruction, bool) { + var scratch instructionOperandScratch + states := make(map[stackLivenessState]bool) + _, slotAddress := v.(*ssa.Alloc) + return p.lastUseInBlockValue(v, blk, order, states, slotAddress, &scratch) +} + +func (p *context) lastUseInBlockValue( + v ssa.Value, + blk *ssa.BasicBlock, + order map[ssa.Instruction]int, + seen map[stackLivenessState]bool, + slotAddress bool, + scratch *instructionOperandScratch, +) (ssa.Instruction, bool) { + state := stackLivenessState{value: v, slotAddress: slotAddress} + if v == nil || seen[state] { + return nil, true + } + seen[state] = true + refs := v.Referrers() + if refs == nil { + return nil, true + } + // x/tools defines Referrers for function-local values as the inverse of + // Instruction.Operands. Rely on that builder contract for completeness, + // but reject stale entries that no longer name v or are no longer + // scheduled in this block. + var last ssa.Instruction + updateLast := func(instr ssa.Instruction) { + if instr == nil { + return + } + if last == nil || order[instr] > order[last] { + last = instr + } + } + for _, ref := range *refs { + switch ref := ref.(type) { + case *ssa.DebugRef: + continue + case *ssa.Defer, *ssa.Go, *ssa.MakeClosure, *ssa.Phi: + return nil, false + default: + instr, ok := ref.(ssa.Instruction) + if !ok || !scratch.uses(instr, v) { + return nil, false + } + if instr.Block() != blk { + return nil, false + } + if _, ok := order[instr]; !ok { + return nil, false + } + if slotAddress && instructionRetainsAddress(instr, v) { + return nil, false + } + if refVal, ok := ref.(ssa.Value); ok { + nextSlotAddress := slotAddress + if unop, ok := refVal.(*ssa.UnOp); ok && unop.Op == token.MUL { + // A load copies the slot contents; the result no longer + // aliases the stack storage that will be cleared. + nextSlotAddress = false + } + use, ok := p.lastUseInBlockValue(refVal, blk, order, seen, nextSlotAddress, scratch) + if !ok { + return nil, false + } + if use != nil { + updateLast(use) + continue + } + } + updateLast(instr) + } + } + return last, true +} + +func (p *context) collectStackClearPlans(fn *ssa.Function) map[ssa.Instruction][]*ssa.Alloc { + plans := make(map[ssa.Instruction][]*ssa.Alloc) + blockCyclicity := cyclicBlocks(fn.Blocks) + for _, blk := range fn.Blocks { + if blockCyclicity[blk] { + continue + } + var order map[ssa.Instruction]int + for _, instr := range blk.Instrs { + alloc, ok := instr.(*ssa.Alloc) + if !ok || !p.shouldClearAlloc(alloc) { + continue + } + // Deliberately limit clearing to exact, non-escaping slots whose + // complete use graph stays in one acyclic basic block. This can + // retain stale roots, but it cannot guess across control-flow, + // closure, defer, goroutine, or heap-escape boundaries. + useBlk := alloc.Block() + if useBlk == nil || useBlk != blk { + continue + } + if order == nil { + order = make(map[ssa.Instruction]int, len(blk.Instrs)) + for i, useInstr := range blk.Instrs { + order[useInstr] = i + } + } + last, ok := p.lastUseInBlock(alloc, useBlk, order) + if ok && last != nil && !isTerminatingInstruction(last) { + plans[last] = append(plans[last], alloc) + } + } + } + return plans +} + +func (p *context) clearAlloc(b llssa.Builder, alloc *ssa.Alloc) { + // Eligible allocs are lowered before their later clear sites. Reuse that + // exact stack pointer; rematerializing the alloc here would clear unrelated + // storage and invalidate the liveness proof. + ptr, ok := p.bvals[alloc] + if !ok { + log.Panicln("stack clear for unmaterialized alloc:", alloc) + } + elem := b.Prog.Elem(ptr.Type) + b.StoreVolatile(ptr, p.prog.Zero(elem)) +} + +func (p *context) clearDeadAllocs(b llssa.Builder, instr ssa.Instruction) { + allocs := p.stackClears[instr] + if len(allocs) == 0 { + return + } + for _, alloc := range allocs { + p.clearAlloc(b, alloc) + } +} + func isPhi(i ssa.Instruction) bool { _, ok := i.(*ssa.Phi) return ok diff --git a/cl/liveness_internal_test.go b/cl/liveness_internal_test.go new file mode 100644 index 0000000000..423f1a2ec6 --- /dev/null +++ b/cl/liveness_internal_test.go @@ -0,0 +1,1003 @@ +//go:build !llgo +// +build !llgo + +package cl + +import ( + "go/ast" + "go/parser" + "go/token" + "go/types" + "strings" + "testing" + + "github.com/goplus/gogen/packages" + "golang.org/x/tools/go/ssa" + "golang.org/x/tools/go/ssa/ssautil" +) + +func buildSSAPackageWithPath(t *testing.T, pkgPath, pkgName, src string) *ssa.Package { + t.Helper() + ssapkg, _ := buildSSAPackageWithPathAndFiles(t, pkgPath, pkgName, src) + return ssapkg +} + +func buildSSAPackageWithPathAndFiles(t *testing.T, pkgPath, pkgName, src string) (*ssa.Package, []*ast.File) { + t.Helper() + return buildSSAPackageWithPathAndFilesMode(t, pkgPath, pkgName, src, ssa.SanityCheckFunctions|ssa.InstantiateGenerics) +} + +func buildSSAPackageWithPathAndFilesMode(t *testing.T, pkgPath, pkgName, src string, mode ssa.BuilderMode) (*ssa.Package, []*ast.File) { + t.Helper() + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "p.go", src, 0) + if err != nil { + t.Fatal(err) + } + files := []*ast.File{f} + pkg := types.NewPackage(pkgPath, pkgName) + imp := packages.NewImporter(fset) + ssapkg, _, err := ssautil.BuildPackage(&types.Config{Importer: imp}, fset, pkg, files, mode) + if err != nil { + t.Fatal(err) + } + return ssapkg, files +} + +func TestConservativeGCPointerTypeAnalysis(t *testing.T) { + if hasConservativeGCPointers(nil, map[types.Type]bool{}) { + t.Fatal("nil type should not report conservative pointers") + } + if hasConservativeGCPointers(types.Typ[types.Int], map[types.Type]bool{}) { + t.Fatal("int should not report conservative pointers") + } + if hasConservativeGCPointers(types.Typ[types.String], map[types.Type]bool{types.Typ[types.String]: true}) { + t.Fatal("seen type should short-circuit") + } + for _, typ := range []types.Type{ + types.Typ[types.String], + types.Typ[types.UnsafePointer], + types.NewPointer(types.Typ[types.Int]), + types.NewSlice(types.Typ[types.Int]), + types.NewMap(types.Typ[types.String], types.Typ[types.Int]), + types.NewChan(types.SendRecv, types.Typ[types.Int]), + types.NewSignatureType(nil, nil, nil, nil, nil, false), + types.NewInterfaceType(nil, nil), + types.NewArray(types.NewPointer(types.Typ[types.Int]), 2), + types.NewStruct([]*types.Var{types.NewField(token.NoPos, nil, "p", types.NewPointer(types.Typ[types.Int]), false)}, nil), + } { + if !hasConservativeGCPointers(typ, map[types.Type]bool{}) { + t.Fatalf("%v should report conservative pointers", typ) + } + } + if hasConservativeGCPointers(types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, nil, "i", types.Typ[types.Int], false), + }, nil), map[types.Type]bool{}) { + t.Fatal("struct without pointer fields should not report conservative pointers") + } + if hasConservativeGCPointers(types.NewArray(types.Typ[types.Int], 2), map[types.Type]bool{}) { + t.Fatal("array without pointer elements should not report conservative pointers") + } + if !hasConservativeGCPointers(types.NewStruct([]*types.Var{ + types.NewField(token.NoPos, nil, "i", types.Typ[types.Int], false), + types.NewField(token.NoPos, nil, "p", types.NewPointer(types.Typ[types.Int]), false), + }, nil), map[types.Type]bool{}) { + t.Fatal("struct with later pointer field should report conservative pointers") + } +} + +func TestShouldClearAlloc(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "example.com/live", "live", `package live + +type Box struct{ p *int } + +var Sink any + +func allocs(p *int) { + var box Box + var i int + box.p = p + Sink = &box + Sink = &i +} + `) + fn := ssapkg.Func("allocs") + ctx := &context{} + if ctx.shouldClearAlloc(nil) { + t.Fatal("nil alloc should not be cleared") + } + + var boxAlloc, intAlloc *ssa.Alloc + for _, local := range functionAllocs(fn) { + ptr := local.Type().Underlying().(*types.Pointer) + if _, ok := ptr.Elem().Underlying().(*types.Struct); ok { + boxAlloc = local + } + if ptr.Elem() == types.Typ[types.Int] { + intAlloc = local + } + } + if boxAlloc == nil || intAlloc == nil { + var dump strings.Builder + fn.WriteTo(&dump) + t.Fatalf("missing expected allocs: %v\n%s", functionAllocs(fn), dump.String()) + } + if !boxAlloc.Heap { + t.Fatal("address-taken box should be marked as a heap allocation") + } + if ctx.shouldClearAlloc(boxAlloc) { + t.Fatal("heap allocation must not be cleared") + } + if ctx.shouldClearAlloc(intAlloc) { + t.Fatal("int alloc should not be cleared") + } + + boxAlloc.Heap = false + if !ctx.shouldClearAlloc(boxAlloc) { + t.Fatal("non-heap stack slot containing a pointer should be cleared") + } + boxAlloc.Comment = "varargs" + if ctx.shouldClearAlloc(boxAlloc) { + t.Fatal("varargs alloc should not be cleared") + } + boxAlloc.Comment = "makeslice" + if ctx.shouldClearAlloc(boxAlloc) { + t.Fatal("synthetic makeslice alloc should not be cleared") + } +} + +func functionAllocs(fn *ssa.Function) []*ssa.Alloc { + seen := make(map[*ssa.Alloc]bool) + var allocs []*ssa.Alloc + add := func(alloc *ssa.Alloc) { + if alloc != nil && !seen[alloc] { + seen[alloc] = true + allocs = append(allocs, alloc) + } + } + for _, local := range fn.Locals { + add(local) + } + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if alloc, ok := instr.(*ssa.Alloc); ok { + add(alloc) + } + } + } + return allocs +} + +func TestRuntimeSetFinalizerDetection(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "github.com/goplus/llgo/runtime/livetest", "livetest", `package livetest + +import rt "runtime" + +func direct(p *int) { + rt.SetFinalizer(p, func(*int) {}) +} + +func deferred(p *int) { + defer rt.SetFinalizer(p, nil) +} + +func goroutine(p *int) { + go rt.SetFinalizer(p, nil) +} + +func nested(p *int) { + func() { + rt.SetFinalizer(p, nil) + }() +} + +func none(p *int) {} +`) + ctx := &context{} + if ctx.enableConservativeLivenessClears(nil) { + t.Fatal("nil function should not enable conservative clears") + } + for _, name := range []string{"direct", "deferred", "goroutine", "nested"} { + if !ctx.functionUsesRuntimeSetFinalizer(ssapkg.Func(name), map[*ssa.Function]bool{}) { + t.Fatalf("%s should be detected as SetFinalizer user", name) + } + } + if ctx.functionUsesRuntimeSetFinalizer(nil, map[*ssa.Function]bool{}) { + t.Fatal("nil function should not use SetFinalizer") + } + direct := ssapkg.Func("direct") + if ctx.functionUsesRuntimeSetFinalizer(direct, map[*ssa.Function]bool{direct: true}) { + t.Fatal("seen function should short-circuit") + } + if ctx.functionUsesRuntimeSetFinalizer(ssapkg.Func("none"), map[*ssa.Function]bool{}) { + t.Fatal("none should not use SetFinalizer") + } + if ctx.packageUsesRuntimeSetFinalizer(&ssa.Package{Members: map[string]ssa.Member{"none": ssapkg.Func("none")}}) { + t.Fatal("package without SetFinalizer should not report use") + } + if !ctx.packageUsesRuntimeSetFinalizer(ssapkg) { + t.Fatal("package should report SetFinalizer use") + } + if !ctx.enableConservativeLivenessClears(direct) { + t.Error("module package with SetFinalizer should enable conservative clears") + } + ssapkg.Pkg = types.NewPackage("command-line-arguments", "main") + if !ctx.enableConservativeLivenessClears(direct) { + t.Fatal("command-line-arguments package with SetFinalizer should enable conservative clears") + } + + methodPkg := buildSSAPackageWithPath(t, "github.com/goplus/llgo/runtime/methodlive", "methodlive", `package methodlive + +import rt "runtime" + + type setter struct{} + +func (setter) install(p *int) { + rt.SetFinalizer(p, func(*int) {}) +} +`) + if !ctx.packageUsesRuntimeSetFinalizer(methodPkg) { + t.Error("method-only SetFinalizer use should be detected") + } + + genericMethodPkg := buildSSAPackageWithPath(t, "github.com/goplus/llgo/runtime/genericmethodlive", "genericmethodlive", `package genericmethodlive + +import rt "runtime" + +type setter[T any] struct{} + +func (setter[T]) install(p *T) { + rt.SetFinalizer(p, func(*T) {}) +} + +func use(p *int) { + setter[int]{}.install(p) +} +`) + if !ctx.packageUsesRuntimeSetFinalizer(genericMethodPkg) { + t.Error("generic method-only SetFinalizer use should be detected") + } + var genericMethod *ssa.Function + for fn := range ssautil.AllFunctions(genericMethodPkg.Prog) { + if origin := fn.Origin(); origin != nil && origin.Name() == "install" { + genericMethod = fn + break + } + } + if genericMethod == nil { + t.Fatal("missing instantiated generic method") + } + if !ctx.enableConservativeLivenessClears(genericMethod) { + t.Error("instantiated generic method should inherit its package liveness setting") + } +} + +func TestConservativeLivenessPlanCollectors(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "example.com/live", "live", `package live + +type Box struct{ p *int } + +var ( + Sink any + Held *Box +) + +func linear(p *int) { + var first, second Box + first.p = p + second.p = p + Sink = first.p + Sink = second.p + Sink = 1 +} + +func loop(p *int) { + var box Box + box.p = p + for i := 0; i < 2; i++ { + Sink = box.p + } + Sink = 1 +} + +func takes(*int) {} + +func deferred(p *int) { + var box Box + box.p = p + defer takes(box.p) + Sink = 1 +} + +func goroutine(p *int) { + var box Box + box.p = p + go takes(box.p) + Sink = 1 +} + +func takesBox(Box) {} + +func callLocal(p *int) { + var box Box + box.p = p + takesBox(box) + Sink = 1 +} + +func cyclicLocal(p *int, n int) { + for n > 0 { + var first, second Box + first.p = p + second.p = p + Sink = first.p + Sink = second.p + n-- + } +} + +func slicedLocal(p *int) { + var values [1]*int + values[0] = p + slice := values[:] + Sink = slice[0] + Sink = 1 +} + +func phiLocal(p *int, cond bool) { + var left, right Box + left.p = p + right.p = p + var box *Box + if cond { + box = &left + } else { + box = &right + } + Sink = box.p + Sink = 1 +} + +func storedAlias(p *int) { + var box Box + var alias **int + box.p = p + aliasSlot := &alias + *aliasSlot = &box.p + Sink = **aliasSlot + Sink = 1 +} + +func holdBox(box *Box) { + Held = box +} + +func calledAlias(p *int) { + var box Box + box.p = p + holdBox(&box) + Sink = Held.p + Sink = 1 +} +`) + ctx := &context{} + linear := ssapkg.Func("linear") + stackPlans := ctx.collectStackClearPlans(linear) + if len(stackPlans) == 0 { + t.Fatal("linear should produce stack clear plans") + } + var linearAllocs []*ssa.Alloc + for instr := range stackPlans { + if isTerminatingInstruction(instr) { + t.Fatalf("stack clear should not be scheduled after terminator %T", instr) + } + linearAllocs = append(linearAllocs, stackPlans[instr]...) + } + if len(linearAllocs) != 2 { + t.Fatalf("linear should plan both same-block allocations, got %d: %v", len(linearAllocs), stackPlans) + } + if linearAllocs[0].Block() != linearAllocs[1].Block() { + t.Fatalf("linear allocations should share a block: %v, %v", linearAllocs[0].Block(), linearAllocs[1].Block()) + } + + for _, name := range []string{"loop", "deferred", "goroutine"} { + if got := ctx.collectStackClearPlans(ssapkg.Func(name)); len(got) != 0 { + t.Fatalf("%s should fail closed instead of producing clear plans: %v", name, got) + } + } + + callLocal := ssapkg.Func("callLocal") + callPlans := ctx.collectStackClearPlans(callLocal) + if len(callPlans) == 0 { + t.Fatal("callLocal should produce a stack clear plan") + } + for instr := range callPlans { + if _, ok := instr.(*ssa.Call); !ok { + t.Fatalf("callLocal clear must follow its real final use, got %T", instr) + } + } + + cyclicLocal := ssapkg.Func("cyclicLocal") + cyclicLocalBlocks := cyclicBlocks(cyclicLocal.Blocks) + var cyclicBlock *ssa.BasicBlock + var cyclicAllocs int + for _, alloc := range functionAllocs(cyclicLocal) { + if ctx.shouldClearAlloc(alloc) && cyclicLocalBlocks[alloc.Block()] { + if cyclicBlock == nil { + cyclicBlock = alloc.Block() + } + if alloc.Block() == cyclicBlock { + cyclicAllocs++ + } + } + } + if cyclicAllocs < 2 { + var dump strings.Builder + cyclicLocal.WriteTo(&dump) + t.Fatalf("cyclicLocal should contain two eligible allocations in one cyclic block, got %d:\n%s", cyclicAllocs, dump.String()) + } + if got := ctx.collectStackClearPlans(cyclicLocal); len(got) != 0 { + t.Fatalf("cyclicLocal should fail closed instead of producing clear plans: %v", got) + } + + slicedLocal := ssapkg.Func("slicedLocal") + var hasSlice bool + for _, block := range slicedLocal.Blocks { + for _, instr := range block.Instrs { + if _, ok := instr.(*ssa.Slice); ok { + hasSlice = true + } + } + } + if !hasSlice { + t.Fatal("slicedLocal should exercise a slice-derived stack reference") + } + var slicedAlloc *ssa.Alloc + for _, alloc := range functionAllocs(slicedLocal) { + ptr, ok := alloc.Type().Underlying().(*types.Pointer) + if ok { + if _, ok := ptr.Elem().Underlying().(*types.Array); ok { + slicedAlloc = alloc + slicedAlloc.Heap = false + break + } + } + } + if slicedAlloc == nil { + var dump strings.Builder + slicedLocal.WriteTo(&dump) + t.Fatalf("slicedLocal should contain an array allocation:\n%s", dump.String()) + } + if got := ctx.collectStackClearPlans(slicedLocal); len(got) == 0 { + var dump strings.Builder + slicedLocal.WriteTo(&dump) + t.Fatalf("slicedLocal should produce a stack clear plan:\n%s", dump.String()) + } + + phiLocal := ssapkg.Func("phiLocal") + var hasPhi bool + for _, block := range phiLocal.Blocks { + for _, instr := range block.Instrs { + if _, ok := instr.(*ssa.Phi); ok { + hasPhi = true + } + } + } + if !hasPhi { + t.Fatal("phiLocal should exercise a merged stack reference") + } + if got := ctx.collectStackClearPlans(phiLocal); len(got) != 0 { + t.Fatalf("phiLocal should fail closed instead of producing clear plans: %v", got) + } + + findStructAlloc := func(fn *ssa.Function) *ssa.Alloc { + t.Helper() + for _, alloc := range functionAllocs(fn) { + ptr, ok := alloc.Type().Underlying().(*types.Pointer) + if !ok { + continue + } + if _, ok := ptr.Elem().Underlying().(*types.Struct); ok { + return alloc + } + } + var dump strings.Builder + fn.WriteTo(&dump) + t.Fatalf("%s should contain a struct allocation:\n%s", fn.Name(), dump.String()) + return nil + } + + storedAlias := ssapkg.Func("storedAlias") + boxAlloc := findStructAlloc(storedAlias) + var storesFieldPointer bool + for _, ref := range *boxAlloc.Referrers() { + fieldAddr, ok := ref.(*ssa.FieldAddr) + if !ok { + continue + } + for _, block := range storedAlias.Blocks { + for _, instr := range block.Instrs { + if store, ok := instr.(*ssa.Store); ok && store.Val == fieldAddr { + storesFieldPointer = true + } + } + } + } + if !storesFieldPointer { + var dump strings.Builder + storedAlias.WriteTo(&dump) + t.Fatalf("storedAlias should store the address of box.p in another stack slot:\n%s", dump.String()) + } + // Current x/tools marks box as escaping, but do not make safety depend on + // that implementation detail: simulate a less conservative escape result. + boxAlloc.Heap = false + for _, allocs := range ctx.collectStackClearPlans(storedAlias) { + for _, alloc := range allocs { + if alloc == boxAlloc { + t.Fatalf("storedAlias should fail closed for a pointer stored through another stack slot: %v", alloc) + } + } + } + + calledAlias := ssapkg.Func("calledAlias") + calledBoxAlloc := findStructAlloc(calledAlias) + var callsWithAddress bool + for _, ref := range *calledBoxAlloc.Referrers() { + call, ok := ref.(*ssa.Call) + if ok && instructionUsesValue(call, calledBoxAlloc) { + callsWithAddress = true + } + } + if !callsWithAddress { + var dump strings.Builder + calledAlias.WriteTo(&dump) + t.Fatalf("calledAlias should pass the Box address to a call:\n%s", dump.String()) + } + // Likewise, make the call boundary independently fail closed even if a + // future SSA builder no longer heap-promotes the explicit address. + calledBoxAlloc.Heap = false + for _, allocs := range ctx.collectStackClearPlans(calledAlias) { + for _, alloc := range allocs { + if alloc == calledBoxAlloc { + t.Fatalf("calledAlias should fail closed when a call can retain the stack address: %v", alloc) + } + } + } +} + +func TestConservativeLivenessGraphHelpers(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "example.com/live", "live", `package live + +var Sink any + +func flow(p *int, cond bool) { + if cond { + Sink = p + } else { + Sink = 0 + } +} + +func target(*int) {} + +func withCall(p *int) { + target(p) +} + +func loop(p *int) { + for i := 0; i < 2; i++ { + Sink = p + } +} + `) + fn := ssapkg.Func("flow") + if got := cyclicBlocks(nil); len(got) != 0 { + t.Fatalf("nil block list should have no cycles: %v", got) + } + cycleA, cycleB, acyclic := &ssa.BasicBlock{}, &ssa.BasicBlock{}, &ssa.BasicBlock{} + cycleA.Succs = []*ssa.BasicBlock{cycleB} + cycleB.Succs = []*ssa.BasicBlock{cycleA, acyclic} + if got := cyclicBlocks([]*ssa.BasicBlock{cycleA, cycleB, acyclic, nil}); !got[cycleA] || !got[cycleB] || got[acyclic] { + t.Fatalf("SCC cycle classification = %v", got) + } + selfLoop := &ssa.BasicBlock{} + selfLoop.Succs = []*ssa.BasicBlock{selfLoop} + if got := cyclicBlocks([]*ssa.BasicBlock{selfLoop}); !got[selfLoop] { + t.Fatalf("self-loop classification = %v", got) + } + if got := cyclicBlocks(fn.Blocks); len(got) != 0 { + t.Fatalf("flow should have no cyclic blocks: %v", got) + } + loop := ssapkg.Func("loop") + if cyclic := cyclicBlocks(loop.Blocks); len(cyclic) == 0 { + t.Fatal("loop should contain at least one cyclic block") + } + if instructionUsesValue(nil, fn.Params[0]) { + t.Fatal("nil instruction should not use values") + } + if instructionUsesValue(fn.Blocks[0].Instrs[0], nil) { + t.Fatal("nil value should not be used") + } + if !isTerminatingInstruction(fn.Blocks[0].Instrs[len(fn.Blocks[0].Instrs)-1]) { + t.Fatal("entry block should end with a terminator") + } + for name, instr := range map[string]ssa.Instruction{ + "store": &ssa.Store{Val: fn.Params[0]}, + "map-key": &ssa.MapUpdate{Key: fn.Params[0]}, + "map-value": &ssa.MapUpdate{Value: fn.Params[0]}, + "channel": &ssa.Send{X: fn.Params[0]}, + "call": &ssa.Call{Call: ssa.CallCommon{ + Args: []ssa.Value{fn.Params[0]}, + }}, + "call-value": &ssa.Call{Call: ssa.CallCommon{ + Value: fn.Params[0], + }}, + "unclassified-non-value": &ssa.Return{Results: []ssa.Value{fn.Params[0]}}, + "select": &ssa.Select{States: []*ssa.SelectState{{ + Dir: types.SendOnly, + Send: fn.Params[0], + }}}, + } { + if !instructionRetainsAddress(instr, fn.Params[0]) { + t.Errorf("%s should retain an address", name) + } + } + for name, instr := range map[string]ssa.Instruction{ + "store-address": &ssa.Store{Addr: fn.Params[0]}, + "map": &ssa.MapUpdate{Map: fn.Params[0]}, + "channel": &ssa.Send{Chan: fn.Params[0]}, + "pure-value": &ssa.ChangeType{X: fn.Params[0]}, + "select-channel": &ssa.Select{States: []*ssa.SelectState{{ + Dir: types.RecvOnly, + Chan: fn.Params[0], + }}}, + } { + if instructionRetainsAddress(instr, fn.Params[0]) { + t.Errorf("%s should not treat its destination as a stored address", name) + } + } + + ctx := &context{} + if last, ok := ctx.lastUseInBlock(nil, fn.Blocks[0], map[ssa.Instruction]int{}); !ok || last != nil { + t.Fatalf("lastUseInBlock(nil) = %v, %v", last, ok) + } + + withCall := ssapkg.Func("withCall") + var call *ssa.Call + for _, block := range withCall.Blocks { + for _, instr := range block.Instrs { + if callInstr, ok := instr.(*ssa.Call); ok { + call = callInstr + } + } + } + if call == nil { + t.Fatal("withCall should include a call-like instruction") + } + block := call.Block() + order := make(map[ssa.Instruction]int, len(block.Instrs)) + for i, instr := range block.Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(withCall.Params[0], block, order); !ok || last != call { + t.Fatalf("lastUseInBlock(call parameter) = %v, %v; want call", last, ok) + } +} + +func TestConservativeLivenessHelperFallbacks(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "example.com/live", "live", `package live + +var Sink any + +func branch(cond bool) { + if cond { + Sink = 1 + } else { + Sink = 2 + } +} + +func useOne(p, q *int) { + Sink = p +} + +func neg(i int) int { + return -i +} + +func callDeref(f *func()) { + (*f)() +} + +func derefOnly(p **int) { + _ = *p +} + `) + ctx := &context{} + + branch := ssapkg.Func("branch") + if len(branch.Blocks) < 2 { + t.Fatalf("branch should have successors:\n%s", branch.String()) + } + if got := cyclicBlocks(branch.Blocks); len(got) != 0 { + t.Fatalf("branch should have no cyclic blocks: %v", got) + } + + useOne := ssapkg.Func("useOne") + var useP ssa.Instruction + for _, block := range useOne.Blocks { + for _, instr := range block.Instrs { + if instructionUsesValue(instr, useOne.Params[0]) { + useP = instr + break + } + } + if useP != nil { + break + } + } + if useP == nil { + t.Fatalf("missing instruction that uses p:\n%s", useOne.String()) + } + if instructionUsesValue(useP, useOne.Params[1]) { + t.Fatal("instruction using p should not report use of q") + } + global := ssapkg.Members["Sink"].(*ssa.Global) + if last, ok := ctx.lastUseInBlock(global, useOne.Blocks[0], map[ssa.Instruction]int{}); !ok || last != nil { + t.Fatalf("lastUseInBlock(global) = %v, %v", last, ok) + } + + neg := ssapkg.Func("neg") + var negInstr *ssa.UnOp + for _, block := range neg.Blocks { + for _, instr := range block.Instrs { + if unop, ok := instr.(*ssa.UnOp); ok && unop.Op == token.SUB { + negInstr = unop + break + } + } + if negInstr != nil { + break + } + } + if negInstr == nil { + t.Fatalf("missing unary negation:\n%s", neg.String()) + } + order := make(map[ssa.Instruction]int, len(negInstr.Block().Instrs)) + for i, instr := range negInstr.Block().Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(neg.Params[0], negInstr.Block(), order); !ok { + t.Fatalf("lastUseInBlock(neg param) = %v, %v", last, ok) + } else if _, ok := last.(*ssa.Return); !ok { + t.Fatalf("lastUseInBlock(neg param) = %T; want return", last) + } + + callDeref := ssapkg.Func("callDeref") + var deref *ssa.UnOp + for _, block := range callDeref.Blocks { + for _, instr := range block.Instrs { + if unop, ok := instr.(*ssa.UnOp); ok && unop.Op == token.MUL { + deref = unop + break + } + } + if deref != nil { + break + } + } + if deref == nil { + t.Fatalf("missing call dereference:\n%s", callDeref.String()) + } + order = make(map[ssa.Instruction]int, len(deref.Block().Instrs)) + for i, instr := range deref.Block().Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(callDeref.Params[0], deref.Block(), order); !ok { + t.Fatalf("lastUseInBlock(call deref param) = %v, %v", last, ok) + } else if _, ok := last.(*ssa.Call); !ok { + t.Fatalf("lastUseInBlock(call deref param) = %T; want call", last) + } + + derefOnly := ssapkg.Func("derefOnly") + var loneDeref *ssa.UnOp + for _, block := range derefOnly.Blocks { + for _, instr := range block.Instrs { + if unop, ok := instr.(*ssa.UnOp); ok && unop.Op == token.MUL { + loneDeref = unop + break + } + } + if loneDeref != nil { + break + } + } + if loneDeref == nil { + t.Fatalf("missing lone dereference:\n%s", derefOnly.String()) + } + order = make(map[ssa.Instruction]int, len(loneDeref.Block().Instrs)) + for i, instr := range loneDeref.Block().Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(derefOnly.Params[0], loneDeref.Block(), order); !ok || last != loneDeref { + t.Fatalf("lastUseInBlock(lone deref param) = %v, %v; want deref", last, ok) + } +} + +func TestConservativeLivenessMalformedReferrersFailClosed(t *testing.T) { + ssapkg := buildSSAPackageWithPath(t, "example.com/live", "live", `package live + +var Sink any + +func use(p *int) { + Sink = p +} +`) + fn := ssapkg.Func("use") + param := fn.Params[0] + ctx := &context{} + + check := func(t *testing.T, ref ssa.Instruction) { + t.Helper() + refs := param.Referrers() + original := append([]ssa.Instruction(nil), (*refs)...) + *refs = []ssa.Instruction{ref} + defer func() { + *refs = original + }() + + order := make(map[ssa.Instruction]int, len(fn.Blocks[0].Instrs)) + for i, instr := range fn.Blocks[0].Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(param, fn.Blocks[0], order); ok || last != nil { + t.Fatalf("lastUseInBlock with malformed referrer = %v, %v; want failure", last, ok) + } + } + + t.Run("missing-order-entry", func(t *testing.T) { + if last, ok := ctx.lastUseInBlock(param, fn.Blocks[0], map[ssa.Instruction]int{}); ok || last != nil { + t.Fatalf("lastUseInBlock with unscheduled referrer = %v, %v; want failure", last, ok) + } + }) + t.Run("unary", func(t *testing.T) { + check(t, &ssa.UnOp{Op: token.SUB, X: param}) + }) + t.Run("dereference", func(t *testing.T) { + check(t, &ssa.UnOp{Op: token.MUL, X: param}) + }) + t.Run("return", func(t *testing.T) { + check(t, &ssa.Return{Results: []ssa.Value{param}}) + }) + t.Run("derived-value", func(t *testing.T) { + var derived ssa.Value + for _, ref := range *param.Referrers() { + if value, ok := ref.(*ssa.MakeInterface); ok { + derived = value + break + } + } + if derived == nil { + t.Fatal("missing MakeInterface derived from parameter") + } + refs := derived.Referrers() + original := append([]ssa.Instruction(nil), (*refs)...) + *refs = []ssa.Instruction{&ssa.Return{Results: []ssa.Value{derived}}} + defer func() { + *refs = original + }() + + order := make(map[ssa.Instruction]int, len(fn.Blocks[0].Instrs)) + for i, instr := range fn.Blocks[0].Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(param, fn.Blocks[0], order); ok || last != nil { + t.Fatalf("lastUseInBlock with malformed derived referrer = %v, %v; want failure", last, ok) + } + }) +} + +func TestConservativeLivenessDebugRefs(t *testing.T) { + ssapkg, _ := buildSSAPackageWithPathAndFilesMode(t, "example.com/live", "live", `package live + +var Sink any + +func use(p *int) { + Sink = p +} + `, ssa.SanityCheckFunctions|ssa.InstantiateGenerics|ssa.GlobalDebug) + + fn := ssapkg.Func("use") + var debugRefs int + for _, block := range fn.Blocks { + for _, instr := range block.Instrs { + if _, ok := instr.(*ssa.DebugRef); ok { + debugRefs++ + } + } + } + if debugRefs == 0 { + t.Fatalf("debug SSA package did not contain DebugRef instructions:\n%s", fn.String()) + } + + ctx := &context{} + order := make(map[ssa.Instruction]int, len(fn.Blocks[0].Instrs)) + for i, instr := range fn.Blocks[0].Instrs { + order[instr] = i + } + if last, ok := ctx.lastUseInBlock(fn.Params[0], fn.Blocks[0], order); !ok || last == nil { + t.Fatalf("lastUseInBlock with DebugRef = %v, %v", last, ok) + } +} + +func TestCompileWithoutConservativeLivenessClears(t *testing.T) { + ssapkg, files := buildSSAPackageWithPathAndFiles(t, "command-line-arguments", "main", `package main + +type Box struct{ p *int } + +var Sink any + +func clearLocal(p *int) { + var box Box + box.p = p + Sink = box.p + Sink = 1 +} + +func main() { + x := 1 + clearLocal(&x) +} +`) + + ctx := &context{} + if plans := ctx.collectStackClearPlans(ssapkg.Func("clearLocal")); len(plans) == 0 { + t.Fatal("test fixture should be eligible for conservative liveness clearing") + } + + prog := newLLSSAProg(t) + pkg, err := NewPackage(prog, ssapkg, files) + if err != nil { + t.Fatal(err) + } + if strings.Contains(pkg.String(), "store volatile") { + t.Fatalf("package without SetFinalizer should not emit liveness clears:\n%s", pkg.String()) + } +} + +func TestCompileConservativeLivenessClears(t *testing.T) { + ssapkg, files := buildSSAPackageWithPathAndFiles(t, "github.com/goplus/llgo/runtime/livetest", "main", `package main + +import rt "runtime" + +type Box struct{ p, q *int } + +var Sink any + +func clearLocal(p *int) { + var box Box + box.p = p + box.q = p + Sink = box.p + Sink = box.q + Sink = 1 +} + +func main() { + x := new(int) + rt.SetFinalizer(x, func(*int) {}) + clearLocal(x) +} +`) + + prog := newLLSSAProg(t) + pkg, err := NewPackage(prog, ssapkg, files) + if err != nil { + t.Fatal(err) + } + ir := pkg.String() + if !strings.Contains(ir, "store volatile %main.Box zeroinitializer") { + t.Fatalf("compiled liveness module missing volatile whole-aggregate clear:\n%s", ir) + } +} diff --git a/runtime/internal/clite/bdwgc/bdwgc.go b/runtime/internal/clite/bdwgc/bdwgc.go index 9f0bec38e6..4a07b2d44b 100644 --- a/runtime/internal/clite/bdwgc/bdwgc.go +++ b/runtime/internal/clite/bdwgc/bdwgc.go @@ -108,6 +108,9 @@ func GetGCNo() uintptr //go:linkname GetHeapUsageSafe C.GC_get_heap_usage_safe func GetHeapUsageSafe(heapSize, freeBytes, unmappedBytes, bytesSinceGC, totalBytes *uintptr) +//go:linkname ClearStack C.GC_clear_stack +func ClearStack(arg c.Pointer) c.Pointer + //go:linkname GetMemoryUse C.GC_get_memory_use func GetMemoryUse() uintptr diff --git a/runtime/internal/lib/runtime/runtime_gc.go b/runtime/internal/lib/runtime/runtime_gc.go index d8656f93a4..d7b48ec0a5 100644 --- a/runtime/internal/lib/runtime/runtime_gc.go +++ b/runtime/internal/lib/runtime/runtime_gc.go @@ -36,11 +36,18 @@ func ReadMemStats(m *runtime.MemStats) { } func GC() { + // GC_clear_stack only scrubs some inaccessible stack space below this + // frame. It cannot reach dead slots in active callers; compiler-emitted + // volatile clears handle those. This remains useful as best-effort cleanup + // for storage vacated before GC was entered. + bdwgc.ClearStack(nil) bdwgc.Gcollect() runFinalizers() // BDW finalizers are observed on a subsequent collection cycle. // Run one extra cycle so weak-pointer cleanup hooks (unique/weak) see // finalized state before we trigger map cleanup callbacks. + // Scrub some inaccessible stack space again before that second collection. + bdwgc.ClearStack(nil) bdwgc.Gcollect() runFinalizers() unique_runtime_notifyMapCleanup() diff --git a/ssa/memory.go b/ssa/memory.go index f88b4826d5..6bd8f909c8 100644 --- a/ssa/memory.go +++ b/ssa/memory.go @@ -389,6 +389,15 @@ func (b Builder) Store(ptr, val Expr) Expr { return Expr{b.impl.CreateStore(val.impl, ptr.impl), b.Prog.Void()} } +// StoreVolatile stores val at ptr without allowing an optimizer to remove or +// combine the store. Conservative GC stack-slot clearing is externally +// observable even when ordinary program dataflow sees no subsequent load. +func (b Builder) StoreVolatile(ptr, val Expr) Expr { + store := b.Store(ptr, val) + store.impl.SetVolatile(true) + return store +} + // Advance returns the pointer ptr advanced by offset. func (b Builder) Advance(ptr Expr, offset Expr) Expr { dbgInstrf("Advance %v, %v\n", ptr.impl, offset.impl) diff --git a/ssa/ssa_test.go b/ssa/ssa_test.go index 38085a627b..2d1b222506 100644 --- a/ssa/ssa_test.go +++ b/ssa/ssa_test.go @@ -2451,6 +2451,24 @@ attributes #0 = { null_pointer_is_valid "frame-pointer"="non-leaf" } `) } +func TestStoreVolatile(t *testing.T) { + prog := NewProgram(nil) + pkg := prog.NewPackage("bar", "foo/bar") + params := types.NewTuple( + types.NewVar(0, nil, "p", types.NewPointer(types.Typ[types.Int32])), + ) + sig := types.NewSignatureType(nil, nil, nil, params, nil, false) + fn := pkg.NewFunc("clear", sig, InGo) + b := fn.MakeBody(1) + b.StoreVolatile(fn.Param(0), prog.IntVal(0, prog.Int32())) + b.Return() + + ir := fn.impl.String() + if !strings.Contains(ir, "store volatile i32 0, ptr %0") { + t.Fatalf("StoreVolatile did not emit a volatile store:\n%s", ir) + } +} + func TestBasicType(t *testing.T) { type typeInfo struct { typ Type diff --git a/test/go/finalizer_liveness_regression_test.go b/test/go/finalizer_liveness_regression_test.go new file mode 100644 index 0000000000..57ab450dab --- /dev/null +++ b/test/go/finalizer_liveness_regression_test.go @@ -0,0 +1,276 @@ +/* + * 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 gotest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +const finalizerLivenessProbe = `package main + +import ( + "os" + "runtime" + "time" + "unsafe" +) + +type Box struct { + p *int +} + +type HeapObject [8]int64 + +type StackSlots [8]*HeapObject + +var ( + savedClosure func() + savedBox *Box + expected uintptr + evalSlot *int +) + +func loopCase() { + x := 42 + var box Box + box.p = &x + for i := 0; i < 3; i++ { + if box.p == nil || *box.p != 42 { + panic("box was cleared while live across loop backedge") + } + } +} + +func closureCase() { + x := 42 + box := Box{p: &x} + savedClosure = func() { + if box.p == nil || *box.p != 42 { + panic("captured heap allocation was cleared") + } + } + savedClosure() +} + +func globalEscapeCase() { + x := 42 + savedBox = &Box{p: &x} + if savedBox.p == nil || *savedBox.p != 42 { + panic("globally escaped heap allocation was cleared") + } +} + +//go:noinline +func checkDeferred(box *Box) { + if box.p == nil || *box.p != 42 { + panic("deferred argument was cleared before RunDefers") + } +} + +func deferCase() { + x := 42 + box := Box{p: &x} + defer checkDeferred(&box) +} + +//go:noinline +func consumeBox(*Box) {} + +//go:noinline +func checkAlias(p *int) { + if p == nil || *p != 42 { + panic("independent live alias was cleared") + } +} + +func aliasCase() { + h := new(int) + *h = 42 + box := Box{p: h} + alias := h + consumeBox(&box) + checkAlias(alias) +} + +func goroutineCase() { + x := 42 + box := Box{p: &x} + start := make(chan struct{}) + done := make(chan struct{}) + go func(p *Box) { + <-start + if p.p == nil || *p.p != 42 { + panic("goroutine argument was cleared before use") + } + close(done) + }(&box) + close(start) + <-done +} + +func uintptrCase() { + h := new(int) + box := Box{p: h} + bits := uintptr(unsafe.Pointer(h)) + expected = bits + consumeBox(&box) + if bits != expected { + panic("live uintptr bits were rewritten by stack scan") + } +} + +func clearEvalSlot() any { + evalSlot = nil + return func(*int) {} +} + +//go:noinline +func loadEvalSlot() *int { + return evalSlot +} + +func evalOrderCase() { + p := new(int) + evalSlot = p + runtime.SetFinalizer(loadEvalSlot(), clearEvalSlot()) + runtime.KeepAlive(p) +} + +//go:noinline +func sameBlockFinalizationCase(writeIndex, readIndex int) { + finalized := make(chan struct{}, 1) + var slots StackSlots + // Keep the dynamic store and load distinct in SSA while the caller supplies + // the same index, so this exercises clearing the exact dead stack allocation. + slots[writeIndex] = new(HeapObject) + runtime.SetFinalizer(slots[readIndex], func(*HeapObject) { + finalized <- struct{}{} + }) + + for i := 0; i < 100; i++ { + runtime.GC() + select { + case <-finalized: + return + default: + } + runtime.Gosched() + time.Sleep(10 * time.Millisecond) + } + panic("same-block dead stack slot kept finalizer object alive") +} + +func storedAliasCase() { + x := 42 + var box Box + var alias **int + box.p = &x + aliasSlot := &alias + *aliasSlot = &box.p + if **aliasSlot == nil || ***aliasSlot != 42 { + panic("stack slot was cleared before a stored alias read") + } +} + +func main() { + if len(os.Args) != 2 { + panic("missing case name") + } + activation := new(int) + runtime.SetFinalizer(activation, func(*int) {}) + switch os.Args[1] { + case "loop": + loopCase() + case "closure": + closureCase() + case "global-escape": + globalEscapeCase() + case "defer": + deferCase() + case "alias": + aliasCase() + case "goroutine": + goroutineCase() + case "uintptr": + uintptrCase() + case "eval-order": + evalOrderCase() + case "same-block-finalization": + index := len(os.Args[1]) & (len(StackSlots{}) - 1) + sameBlockFinalizationCase(index, index) + case "stored-alias": + storedAliasCase() + default: + panic("unknown case") + } + runtime.KeepAlive(activation) +} +` + +func buildFinalizerLivenessProbe(t *testing.T) (hostBin, llgoBin string) { + t.Helper() + dir := t.TempDir() + mainFile := filepath.Join(dir, "main.go") + if err := os.WriteFile(mainFile, []byte(finalizerLivenessProbe), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/finalizerprobe\n\ngo 1.24\n"), 0o644); err != nil { + t.Fatal(err) + } + + hostBin = filepath.Join(dir, "host-probe") + runGoCmd(t, dir, "build", "-o", hostBin, ".") + + llgoBin = filepath.Join(dir, "llgo-probe") + out, err := runLLGoInModule(t, dir, "build", "-o", llgoBin, ".") + if err != nil { + t.Fatalf("llgo build failed: %v\n%s", err, out) + } + return hostBin, llgoBin +} + +func runFinalizerLivenessProbe(t *testing.T, bin, caseName string) { + t.Helper() + out, err := exec.Command(bin, caseName).CombinedOutput() + if err != nil { + t.Fatalf("%s failed: %v\n%s", filepath.Base(bin), err, out) + } +} + +func TestRuntimeSetFinalizerPreservesLiveValues(t *testing.T) { + hostBin, llgoBin := buildFinalizerLivenessProbe(t) + for _, caseName := range []string{ + "loop", + "closure", + "global-escape", + "defer", + "alias", + "goroutine", + "uintptr", + "eval-order", + "same-block-finalization", + "stored-alias", + } { + t.Run(caseName, func(t *testing.T) { + runFinalizerLivenessProbe(t, hostBin, caseName) + runFinalizerLivenessProbe(t, llgoBin, caseName) + }) + } +} diff --git a/test/goroot/xfail.yaml b/test/goroot/xfail.yaml index cee4671e72..3bb23b9e96 100644 --- a/test/goroot/xfail.yaml +++ b/test/goroot/xfail.yaml @@ -2788,16 +2788,6 @@ xfails: directive: runoutput case: rangegen.go reason: go1.26 goroot ci-mode runoutput failure on linux/amd64 - - version: go1.24 - platform: darwin/arm64 - directive: run - case: deferfin.go - reason: latest main goroot run failure on darwin/arm64 - - version: go1.25 - platform: darwin/arm64 - directive: run - case: deferfin.go - reason: latest main goroot run failure on darwin/arm64 - platform: darwin/arm64 directive: run case: heapsampling.go @@ -2821,11 +2811,11 @@ xfails: - platform: darwin/arm64 directive: run case: stackobj.go - reason: latest main goroot run failure on darwin/arm64 + reason: conservative GC lacks precise cross-frame stack-object liveness - platform: darwin/arm64 directive: run case: stackobj3.go - reason: latest main goroot run failure on darwin/arm64 + reason: conservative GC lacks precise ambiguous-parameter liveness - platform: darwin/arm64 directive: run case: fixedbugs/bug347.go @@ -2888,7 +2878,7 @@ xfails: platform: linux/amd64 directive: run case: deferfin.go - reason: go1.25 goroot run failure on linux/amd64 + reason: exiting pthread may retain conservative stack or register roots after goroutine completion - version: go1.25 platform: linux/amd64 directive: run @@ -3008,12 +2998,12 @@ xfails: platform: linux/amd64 directive: run case: stackobj.go - reason: go1.25 goroot run failure on linux/amd64 + reason: conservative GC lacks precise cross-frame stack-object liveness - version: go1.25 platform: linux/amd64 directive: run case: stackobj3.go - reason: go1.25 goroot run failure on linux/amd64 + reason: conservative GC lacks precise ambiguous-parameter liveness - version: go1.25 platform: linux/amd64 directive: run @@ -3024,7 +3014,7 @@ xfails: platform: linux/amd64 directive: run case: deferfin.go - reason: go1.24 goroot run failure on linux/amd64 + reason: exiting pthread may retain conservative stack or register roots after goroutine completion - version: go1.24 platform: linux/amd64 directive: run @@ -3089,12 +3079,12 @@ xfails: platform: linux/amd64 directive: run case: stackobj.go - reason: go1.24 goroot run failure on linux/amd64 + reason: conservative GC lacks precise cross-frame stack-object liveness - version: go1.24 platform: linux/amd64 directive: run case: stackobj3.go - reason: go1.24 goroot run failure on linux/amd64 + reason: conservative GC lacks precise ambiguous-parameter liveness - version: go1.24 platform: linux/amd64 directive: run @@ -3169,7 +3159,7 @@ xfails: platform: linux/amd64 directive: run case: deferfin.go - reason: go1.26 goroot run failure on linux/amd64 + reason: exiting pthread may retain conservative stack or register roots after goroutine completion - version: go1.26 platform: linux/amd64 directive: run @@ -3189,12 +3179,12 @@ xfails: platform: linux/amd64 directive: run case: stackobj.go - reason: go1.26 goroot run failure on linux/amd64 + reason: conservative GC lacks precise cross-frame stack-object liveness - version: go1.26 platform: linux/amd64 directive: run case: stackobj3.go - reason: go1.26 goroot run failure on linux/amd64 + reason: conservative GC lacks precise ambiguous-parameter liveness - version: go1.26 platform: linux/amd64 directive: run