Skip to content
473 changes: 473 additions & 0 deletions cl/compile.go

Large diffs are not rendered by default.

1,003 changes: 1,003 additions & 0 deletions cl/liveness_internal_test.go

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions runtime/internal/clite/bdwgc/bdwgc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions runtime/internal/lib/runtime/runtime_gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
cpunion marked this conversation as resolved.
Comment thread
cpunion marked this conversation as resolved.
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()
Expand Down
9 changes: 9 additions & 0 deletions ssa/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions ssa/ssa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
276 changes: 276 additions & 0 deletions test/go/finalizer_liveness_regression_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading