blob: b9a6df050d0f42b5917e04042e26a1c63bec66da [file] [log] [blame]
// Copyright Istio Authors
//
// 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.
// leak checks for goroutine leaks in tests
// This is (heavily) inspired by https://github.com/grpc/grpc-go/blob/master/internal/leakcheck/leakcheck.go
// and https://github.com/fortytw2/leaktest
package leak
import (
"errors"
"fmt"
"os"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
import (
"go.uber.org/atomic"
"istio.io/pkg/log"
)
import (
"github.com/apache/dubbo-go-pixiu/pkg/test"
"github.com/apache/dubbo-go-pixiu/pkg/test/util/retry"
)
var goroutinesToIgnore = []string{
// "global" goroutines we always initialize. Maybe we shouldn't always initialize these, but for now every
// test fails with these
"k8s.io/klog/v2.(*loggingT).flushDaemon", // k8s logging
"go.opencensus.io/stats/view.(*worker).start", // metrics runs on init. Maybe it should be in main()
// goroutines for test
"testing.Main(",
"testing.tRunner(",
"testing.(*M).",
// go runtime
"runtime.goexit",
"created by runtime.gc",
"runtime.MHeap_Scavenger",
"signal.signal_recv",
"sigterm.handler",
"runtime_mcall",
// created by leak checker
"created by runtime/trace.Start",
"interestingGoroutines",
// This is not technically required. However, its a loop that is outside our control that runs every 500ms
// so we skip it to avoid delayed tests
"workqueue.(*Type).updateUnfinishedWorkLoop",
}
// TestingM is the minimal subset of testing.M that we use.
type TestingM interface {
Run() int
}
type TestingTB interface {
Cleanup(func())
Errorf(format string, args ...interface{})
}
var gracePeriod = time.Second * 5
func check(filter func(in []*goroutine) []*goroutine) error {
// Loop, waiting for goroutines to shut down.
// Wait up to timeout, but finish as quickly as possible.
// The timeout here is not super sensitive, since if we hit this we will fail; a happy case will finish quickly
deadline := time.Now().Add(gracePeriod)
var leaked []*goroutine
var err error
delay := time.Duration(0)
for time.Now().Before(deadline) {
leaked, err = interestingGoroutines()
if err != nil {
return fmt.Errorf("failed to fetch post-test goroutines: %v", err)
}
if filter != nil {
leaked = filter(leaked)
}
if len(leaked) == 0 {
return nil
}
time.Sleep(delay)
delay += time.Millisecond * 10
}
errString := strings.Builder{}
for _, g := range leaked {
errString.WriteString(fmt.Sprintf("Leaked goroutine: %v\n", g.stack))
}
return errors.New(errString.String())
}
// Check adds a check to a test to ensure there are no leaked goroutines
// To use, simply call leak.Check(t) at the start of a test; Do not call it in defer.
// It is recommended to call this as the first step, as Cleanup is called in LIFO order; this ensures any
// Cleanup's called in the test happen first.
// Any existing goroutines before the test starts are filtered out. This ensures a single test failing doesn't
// cause all future tests to fail. However, it is still possible another test influences the result when t.Parallel is used.
// Where possible, CheckMain is preferred.
func Check(t TestingTB) {
existingRaw, err := interestingGoroutines()
if err != nil {
t.Errorf("failed to fetch pre-test goroutines: %v", err)
return
}
existing := map[uint64]struct{}{}
for _, g := range existingRaw {
existing[g.id] = struct{}{}
}
filter := func(in []*goroutine) []*goroutine {
res := make([]*goroutine, 0, len(in))
for _, i := range in {
if _, f := existing[i.id]; !f {
// This was not in the goroutines list when the test started
res = append(res, i)
}
}
return res
}
t.Cleanup(func() {
if err := check(filter); err != nil {
t.Errorf("goroutine leak: %v", err)
}
})
}
// CheckMain asserts that no goroutines are leaked after a test package exits.
// This can be used with the following code:
//
// func TestMain(m *testing.M) {
// leak.CheckMain(m)
// }
//
// Failures here are scoped to the package, not a specific test. To determine the source of the failure,
// you can use the tool `go test -exec $PWD/tools/go-ordered-test ./my/package`. This runs each test individually.
// If there are some tests that are leaky, you the Check method can be used on individual tests.
func CheckMain(m TestingM) {
exitCode := m.Run()
if exitCode == 0 {
if err := check(nil); err != nil {
log.Errorf("fatal: %v", err)
exitCode = 1
}
}
os.Exit(exitCode)
}
// MustGarbageCollect asserts than an object was garbage collected by the end of the test.
// The input must be a pointer to an object.
func MustGarbageCollect(tb test.Failer, i interface{}) {
tb.Helper()
collected := atomic.NewBool(false)
runtime.SetFinalizer(i, func(x interface{}) {
collected.Store(true)
})
tb.Cleanup(func() {
retry.UntilOrFail(tb, func() bool {
// Trigger GC explicitly, otherwise we may need to wait a long time for it to run
runtime.GC()
return collected.Load()
}, retry.Timeout(time.Second*5), retry.Message("object was not garbage collected"))
})
}
type goroutine struct {
id uint64
stack string
}
type goroutineByID []*goroutine
func (g goroutineByID) Len() int { return len(g) }
func (g goroutineByID) Less(i, j int) bool { return g[i].id < g[j].id }
func (g goroutineByID) Swap(i, j int) { g[i], g[j] = g[j], g[i] }
func interestingGoroutine(g string) (*goroutine, error) {
sl := strings.SplitN(g, "\n", 2)
if len(sl) != 2 {
return nil, fmt.Errorf("error parsing stack: %q", g)
}
stack := strings.TrimSpace(sl[1])
if strings.HasPrefix(stack, "testing.RunTests") {
return nil, nil
}
for _, s := range goroutinesToIgnore {
if strings.Contains(stack, s) {
return nil, nil
}
}
// Parse the goroutine's ID from the header line.
h := strings.SplitN(sl[0], " ", 3)
if len(h) < 3 {
return nil, fmt.Errorf("error parsing stack header: %q", sl[0])
}
id, err := strconv.ParseUint(h[1], 10, 64)
if err != nil {
return nil, fmt.Errorf("error parsing goroutine id: %s", err)
}
return &goroutine{id: id, stack: strings.TrimSpace(g)}, nil
}
// interestingGoroutines returns all goroutines we care about for the purpose
// of leak checking. It excludes testing or runtime ones.
func interestingGoroutines() ([]*goroutine, error) {
buf := make([]byte, 2<<20)
buf = buf[:runtime.Stack(buf, true)]
var gs []*goroutine
for _, g := range strings.Split(string(buf), "\n\n") {
gr, err := interestingGoroutine(g)
if err != nil {
return nil, err
} else if gr == nil {
continue
}
gs = append(gs, gr)
}
sort.Sort(goroutineByID(gs))
return gs, nil
}