diff --git a/docs/css/layout.css b/docs/css/layout.css index f5dfbf70c..e860ef91f 100644 --- a/docs/css/layout.css +++ b/docs/css/layout.css @@ -59,6 +59,13 @@ img[alt="Ginkgo"] { margin-right: auto; } +img[alt="Leakiee"] { + max-width: 80%; + max-height: 150px; + display: block; + float: right; +} + /* code styling */ .markdown-body div.highlight { diff --git a/docs/images/leakiee.png b/docs/images/leakiee.png new file mode 100644 index 000000000..063c6fb42 Binary files /dev/null and b/docs/images/leakiee.png differ diff --git a/docs/index.md b/docs/index.md index 38c14e433..6f1ddd934 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2984,4 +2984,292 @@ Describe("server performance", func() { }) ``` +## `gleak`: Finding Leaked Goroutines + +![Leakiee](./images/leakiee.png) + +The `gleak` package provides support for goroutine leak detection. + +> **Please note:** gleak is an experimental new Gomega package. + +### Basics + +Calling `Goroutines` returns information about all goroutines of a program at +this moment. `Goroutines` typically gets invoked in the form of +`Eventually(Goroutines).ShouldNot(...)`. Please note the missing `()` after +`Goroutines`, as it must be called by `Eventually` and **not before it** with +its results passed to `Eventually` only once. This does not preclude calling +`Goroutines()`, such as for taking goroutines snapshots. + +Leaked goroutines are then detected by using `gleak`'s `HaveLeaked` matcher on +the goroutines information. `HaveLeaked` checks the actual list of goroutines +against a built-in list of well-known runtime and testing framework goroutines, +as well as against any optionally additional goroutines specifications passed to +`HaveLeaked`. Good, and thus "non-leaky", Goroutines can be identified in +multiple ways: such as by the name of a topmost function on a goroutine stack or +a snapshot of goroutine information taken before a test. Non-leaky goroutines +can also be identified using basically any Gomega matcher, with `HaveField` or +`WithTransform` being highly useful in test-specific situations. + +The `HaveLeaked` matcher _succeeds_ if it finds any goroutines that are neither +in the integrated list of well-known goroutines nor in the optionally specified +`HaveLeaked` arguments. In consequence, any _success_ of `HaveLeaked` actually +is meant to be a _failure_, because of leaked goroutines. `HaveLeaked` is thus +mostly used in combination with `ShouldNot` and `NotTo`/`ToNot`. + +### Testing for Goroutine Leaks + +In its most simple form, just run a goroutine discovery with a leak check right +_after_ each test in `AfterEach`: + +> **Important:** always use `Goroutines` and not `Goroutines()` in the call to +> `Eventually`. This ensures that the goroutine discovery is correctly done +> repeatedly as needed and not just a single time before calling `Eventually`. + +```go +AfterEach(func() { + Eventually(Goroutines).ShouldNot(HaveLeaked()) +}) +``` + +Using `Eventually` instead of `Ω`/`Expect` has the benefit of retrying the leak +check periodically until there is no leak or until a timeout occurs. This +ensures that goroutines that are (still) in the process of winding down can +correctly terminate without triggering false positives. Please refer to the +[`Eventually`](#eventually) section for details on how to change the timeout +interval (which defaults to 1s) and the polling interval (which defaults to +10ms). + +This form of goroutine leak test can cause false positives in situations where a +test suite or dependency module uses additional goroutines. This simple form +only looks at all goroutines _after_ a test has run and filters out all +_well-known_ "non-leaky" goroutines, such as goroutines from Go's runtime and +the testing frameworks (such as Go's own testing package and Gomega). + +### Using Goroutine Snapshots in Leak Testing + +Often, it might be sufficient to cover for additional "non-leaky" goroutines by +taking a "snapshot" of goroutines _before_ a test and then _afterwards_ use ths +snapshot to filter out the supposedly "non-leaky" goroutines. + +```go +var ignoreGood []goroutine.Goroutine + +BeforeEach(func() { + ignoreGood = Goroutines() +}) + +AfterEach(func() { + Eventually(Goroutines).ShouldNot(HaveLeaked(ignoreGood)) +}) +``` + +### `HaveLeaked` Matcher + +```go +Eventually(ACTUAL).ShouldNot(HaveLeaked(NONLEAKY1, NONLEAKY2, NONLEAKY3, ...)) +``` + +causes a test to fail if `ACTUAL` after filtering out the well-known "good" (and +non-leaky) goroutines of the Go runtime and test frameworks, as well as +filtering out the additional non-leaky goroutines passed to the matcher, still +results in one or more goroutines. The ordering of the goroutines does not +matter. + +`HaveLeaked` always takes the built-in list of well-known good goroutines into +consideration and this list can neither be overriden nor disabled. Additional +known non-leaky goroutines `NONLEAKY1`, ... can be passed to `HaveLeaks` either +in form of `GomegaMatcher`s or in shorthand notation: + +- `"foo.bar"` is shorthand for `IgnoringTopFunction("foo.bar")` and filters out + any (non-leaky) goroutine with its topmost function on the backtrace stack + having the exact name `foo.bar`. + +- `"foo.bar..."` is shorthand for `IgnoringTopFunction("foo.bar...")` and + filters out any (non-leaky) goroutine with its topmost function on the + backtrace stack beginning with the prefix `foo.bar.`; please notice the + trailing `.` dot. + +- `"foo.bar [chan receive]"` is shorthand for `IgnoringTopFunction("foo.bar + [chan receive]")` and filters out any (non-leaky) goroutine where its topmost + function on the backtrace stack has the exact name `foo.bar` _and_ the + goroutine is in a state beginning with `chan receive`. + +- `[]goroutine.Goroutine` is shorthand for + `IgnoringGoroutines()`: it filters out the specified + goroutines, considering them to be non-leaky. The goroutines are identified by + their [goroutine IDs](#goroutine-ids). + +- `IgnoringInBacktrace("foo.bar.baz")` filters out any (non-leaky) goroutine + with `foo.bar.baz` _anywhere_ in its backtrace. + +- additionally, any other `GomegaMatcher` can be passed to `HaveLeaked()`, as + long as this matcher can work on a passed-in actual value of type + `goroutine.Goroutine`. + +### Goroutine Matchers + +The `gleak` packages comes with a set of predefined Goroutine matchers, to be +used with `HaveLeaked`. If these matchers succeed (that is, they match on a +certain `Goroutine`), then `HaveLeaked` considers the matched goroutine to be +non-leaky. + +#### IgnoringTopFunction(topfname string) + +```go +Eventually(ACTUAL).ShouldNot(HaveLeaked(IgnoringTopFunction(TOPFNAME))) +``` + +In its most basic form, `IgnoringTopFunction` succeeds if `ACTUAL` contains a +goroutine where the name of the topmost function on its call stack (backtrace) +is `TOPFNAME`, causing `HaveLeaked` to filter out the matched goroutine as +non-leaky. Different forms of `TOPFNAME` describe different goroutine matching +criteria: + +- `"foo.bar"` matches only if a goroutine's topmost function has this exact name + (ignoring any function parameters). +- `"foo.bar..."` matches if a goroutine's topmost function name starts with the + prefix `"foo.bar."`; it doesn't match `"foo.bar"` though. +- `"foo.bar [state]"` matches if a goroutine's topmost function has this exact + name and the goroutine's state begins with the specified state string. + +`ACTUAL` must be an array or slice of `goroutine.Goroutine`s. + +#### IgnoringGoroutines(goroutines []goroutine.Goroutine) + +```go +Eventually(ACTUAL).ShouldNot(HaveLeaked(IgnoringGoroutines(GOROUTINES))) +``` + +`IgnoringGoroutines` succeeds if `ACTUAL` contains one or more goroutines which +are elements of `GOROUTINES`, causing `HaveLeaked` to filter out the matched +goroutine(s) as non-leaky. `IgnoringGoroutines` compares goroutines by their +`ID`s (see [Goroutine IDs](#gorotuine-ids) for background information). + +`ACTUAL` must be an array or slice of `goroutine.Goroutine`s. + +#### IgnoringInBacktrace(fname string) + +```go +Eventually(Goroutines).ShouldNot(HaveLeaked(IgnoringInBacktrace(FNAME))) +``` + +`IgnoringInBacktrace` succeeds if `ACTUAL` contains a groutine where `FNAME` is +contained anywhere within its call stack (backtrace), causing `HaveLeaked` to +filter out the matched goroutine as non-leaky. Please note that +`IgnoringInBacktrace` uses a (somewhat lazy) `strings.Contains` to check for any +occurence of `FNAME` in backtraces. + +`ACTUAL` must be an array or slice of `goroutine.Goroutine`s. + +#### IgnoringCreator(creatorname string) + +```go +Eventually(Goroutines).ShouldNot(HaveLeaked(IgnoringCreator(CREATORNAME))) +``` + +In its most basic form, `IgnoringCreator` succeeds if `ACTUAL` contains a +groutine where the name of the function creating the goroutine matches +`CREATORNAME`, causing `HaveLeaked` to filter out the matched goroutine(s) as +non-leaky. `IgnoringCreator` uses `==` for comparing the creator function name. + +Different forms of `CREATORNAME` describe different goroutine matching +criteria: + +- `"foo.bar"` matches only if a goroutine's creator function has this exact name + (ignoring any function parameters). +- `"foo.bar..."` matches if a goroutine's creator function name starts with the + prefix `"foo.bar."`; it doesn't match `"foo.bar"` though. + +### Adjusting Leaky Goroutine Reporting + +When `HaveLeaked` finds leaked goroutines, Gomega prints out a description of +(only) the _leaked_ goroutines. This is different from panic output that +contains backtraces of all goroutines. + +However, `noleak`'s goroutine dump deliberately is not subject to Gomega's usual +object rendition controls, such as `format.MaxLength` (see also [Adjusting +Output](#adjusting-output)). + +`noleak` will print leaked goroutine backtraces in a more compact form, with +function calls and locations combined into single lines. Additionally, `noleak` +defaults to reporting only the package plus file name and line number, but not +the full file path. For instance: + + main.foo.func1() at foo/bar.go:123 + +Setting `noleak.ReportFilenameWithPath` to `true` will instead report full +source code file paths: + + main.foo.func1() at /home/coolprojects/ohmyleak/mymodule/foo/bar.go:123 + +### Well-Known Non-Leaky Goroutines + +The well-known good (and therefore "non-leaky") goroutines are identified by the +names of the topmost functions on their stacks (backtraces): + +- signal handling: + - `os/signal.signal_recv` and `os/signal.loop` (covering + varying state), + - as well as `runtime.ensureSigM`. +- Go's built-in [`testing`](https://pkg.go.dev/testing) framework: + - `testing.RunTests [chan receive]`, + - `testing.(*T).Run [chan receive]`, + - and `testing.(*T).Parallel [chan receive]`. +- the [Ginko testing framework](https://onsi.github.io/ginkgo/): + - `github.com/onsi/ginkgo/v2/internal.(*Suite).runNode` (including anonymous + inner functions), + - the anonymous inner functions of + `github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts`, + - and finally + `github.com/onsi/ginkgo/internal/specrunner.(*SpecRunner).registerForInterrupts` + (for v1 support). + +Additionally, any goroutines with `runtime.ReadTrace` in their backtrace stack +are also considered to be non-leaky. + +### Goroutine IDs + +In order to detect goroutine identities, we use what is generally termed +"goroutine IDs". These IDs appear in runtime stack dumps ("backtrace"). But … +are these goroutine IDs even unambiguous? What are their "guarantees", if there +are any at all? + +First, Go's runtime code uses the identifier (and thus term) [`goid` for +Goroutine +IDs](https://github.com/golang/go/search?q=goidgen&unscoped_q=goidgen). Good to +know in case you need to find your way around Go's runtime code. + +Now, based on [Go's `goid` runtime allocation +code](https://github.com/golang/go/blob/release-branch.go1.18/src/runtime/proc.go#L4130), +goroutine IDs never get reused – unless you manage to make the 64bit "master +counter" of the Go runtime scheduler to wrap around. However, not all goroutine +IDs up to the largest one currently seen might ever be used, because as an +optimization goroutine IDs are always assigned to Go's "P" processors for +assignment to newly created "G" goroutines in batches of 16. In consequence, +there may be gaps and later goroutines might have lower goroutine IDs if they +get created from a different P. + +Finally, there's [Scott Mansfield's blog post on Goroutine +IDs](https://blog.sgmansfield.com/2015/12/goroutine-ids/). To sum up Scott's +point of view: don't use goroutine IDs. He spells out good reasons for why you +should not use them. Yet, logging, debugging and testing looks like a sane and +solid exemption from his rule, not least `runtime.Stack` includes the `goids` +for +some reason. + +### Credits + +The _Leakiee the gopher_ mascot clearly has been inspired by the Go gopher art +work of [Renee French](http://reneefrench.blogspot.com/). + +The `gleak` package was heavily inspired by Uber's fine +[goleak](https://github.com/uber-go/goleak) goroutine leak detector package. +While `goleak` can be used with Gomega and Ginkgo in a very specific form, it +unfortunately was never designed to be (optionally) used with a matcher library +to unlock the full potential of reasoning about leaky goroutines. In fact, the +crucial element of discovering goroutines is kept internal in `goleak`. In +consequence, Gomega's `gleak` package uses its own goroutine discovery and is +explicitly designed to perfectly blend in with Gomega (and Ginkgo). + {% endraw %} diff --git a/gleak/doc.go b/gleak/doc.go new file mode 100644 index 000000000..8df345a7a --- /dev/null +++ b/gleak/doc.go @@ -0,0 +1,118 @@ +/* + +package gleak complements the Gingko/Gomega testing and matchers framework with +matchers for Goroutine leakage detection. + +Basics of nleak + +To start with, + + Goroutines() + +returns information about all (non-dead) goroutines at a particular moment. This +is useful to capture a known correct snapshot and then later taking a new +snapshot and comparing these two snapshots for leaked goroutines. + +Next, the matcher + + HaveLeaked(...) + +filters out well-known and expected "non-leaky" goroutines from an actual list +of goroutines (passed from Eventually or Expect), hopefully ending up with an +empty list of leaked goroutines. If there are still goroutines left after +filtering, then HaveLeaked() will succeed ... which usually is actually +considered to be failure. So, this can be rather declared to be "suckcess" +because no one wants leaked goroutines. + +A typical pattern to detect goroutines leaked in individual tests is as follows: + + var ignoreGood []goroutine.Goroutine + + BeforeEach(func() { + ignoreGood = Goroutines() + }) + + AfterEach(func() { + // Note: it's "Goroutines", but not "Goroutines()", when using with Eventually! + Eventually(Goroutines).ShouldNot(HaveLeaked(ignoreGood)) + }) + +Using Eventually instead of Expect ensures that there is some time given for +temporary goroutines to finally wind down. Gomega's default values apply: the 1s +timeout and 10ms polling interval. + +Please note that the form + + HaveLeaked(ignoreGood) + +is the same as the slightly longer, but also more expressive variant: + + HaveLeaked(IgnoringGoroutines(ignoreGood)) + +Leak-Related Matchers + +Depending on your tests and the dependencies used, you might need to identify +additional goroutines as not being leaks. The gleak packages comes with the +following predefined goroutine "filter" matchers that can be specified as +arguments to HaveLeaked(...): + + IgnoringTopFunction("foo.bar") // exactly "foo.bar" + IgnoringTopFunction("foo.bar...") // top function name with prefix "foo.bar." (note the trailing dot!) + IgnoringTopFunction("foo.bar [chan receive]") // exactly "foo.bar" with state starting with "chan receive" + IgnoringGoroutines(expectedGoroutines) // ignore specified goroutines with these IDs + IgnoringInBacktrace("foo.bar.baz") // "foo.bar.baz" within the backtrace + IgnoringCreator("foo.bar") // exact creator function name "foo.bar" + IgnoringCreator("foo.bar...") // creator function name with prefix "foo.bar." + +In addition, you can use any other GomegaMatcher, as long as it can work on a +(single) goroutine.Goroutine. For instance, Gomega's HaveField and WithTransform +matchers are good foundations for writing project-specific gleak matchers. + +Leaked Goroutine Dump + +By default, when gleak's HaveLeaked matcher finds one or more leaked +goroutines, it dumps the goroutine backtraces in a condensed format that uses +only a single line per call instead of two lines. Moreover, the backtraces +abbreviate the source file location in the form of package/source.go:lineno: + + goroutine 42 [flabbergasted] + main.foo.func1() at foo/test.go:6 + created by main.foo at foo/test.go:5 + +By setting gleak.ReportFilenameWithPath=true the leaky goroutine backtraces +will show full path names for each source file: + + goroutine 42 [flabbergasted] + main.foo.func1() at /home/go/foo/test.go:6 + created by main.foo at home/go/foo/test.go:5 + +Acknowledgement + +gleak has been heavily inspired by the Goroutine leak detector +github.com/uber-go/goleak. That's definitely a fine piece of work! + +But then why another goroutine leak package? After a deep analysis of Uber's +goleak we decided against crunching goleak somehow half-assed into the Gomega +TDD matcher ecosystem. In particular, reusing and wrapping of the existing Uber +implementation would have become very awkward: goleak.Find combines all the +different elements of getting actual goroutines information, filtering them, +arriving at a leak conclusion, and even retrying multiple times all in just one +single exported function. Unfortunately, goleak makes gathering information +about all goroutines an internal matter, so we cannot reuse such functionality +elsewhere. + +Users of the Gomega ecosystem are already experienced in arriving at conclusions +and retrying temporarily failing expectations: Gomega does it in form of +Eventually().ShouldNot(), and (without the trying aspect) with Expect().NotTo(). +So what is missing is only a goroutine leak detector in form of the HaveLeaked +matcher, as well as the ability to specify goroutine filters in order to sort +out the non-leaking (and therefore expected) goroutines, using a few filter +criteria. That is, a few new goroutine-related matchers. In this architecture, +even existing Gomega matchers can optionally be (re)used as the need arises. + +References + +https://github.com/onsi/gomega and https://github.com/onsi/ginkgo. + +*/ +package gleak diff --git a/gleak/gleak_suite_test.go b/gleak/gleak_suite_test.go new file mode 100644 index 000000000..30c828477 --- /dev/null +++ b/gleak/gleak_suite_test.go @@ -0,0 +1,13 @@ +package gleak + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGleak(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Gleak Suite") +} diff --git a/gleak/goroutine/doc.go b/gleak/goroutine/doc.go new file mode 100644 index 000000000..6fd6d3845 --- /dev/null +++ b/gleak/goroutine/doc.go @@ -0,0 +1,11 @@ +/* + +Package goroutine discovers and returns information about either all goroutines +or only the caller's goroutine. Information provided by the Goroutine type +consists of a unique ID, the state, the name of the topmost (most recent) +function in the call stack and the full backtrace. For goroutines other than the +main goroutine (the one with ID 1) the creating function as well as location +(file name and line number) are additionally provided. + +*/ +package goroutine diff --git a/gleak/goroutine/goroutine.go b/gleak/goroutine/goroutine.go new file mode 100644 index 000000000..e321ead56 --- /dev/null +++ b/gleak/goroutine/goroutine.go @@ -0,0 +1,232 @@ +package goroutine + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strconv" + "strings" +) + +// Goroutine represents information about a single goroutine, such as its unique +// ID, state, backtrace, creator, and more. +// +// Go's runtime assigns unique IDs to goroutines, also called "goid" in Go +// runtime parlance. These IDs start with 1 for the main goroutine and only +// increase (unless you manage to create 2**64-2 goroutines during the lifetime +// of your tests so that the goids wrap around). Due to runtime-internal +// optimizations, not all IDs might be used, so that there might be gaps. But +// IDs are never reused, so they're fine as unique goroutine identities. +// +// The size of a goidsis always 64bits, even on 32bit architectures (if you +// like, you might want to double-check for yourself in runtime/runtime2.go and +// runtime/proc.go). +// +// A Goroutine's State field starts with one of the following strings: +// - "idle" +// - "runnable" +// - "running" +// - "syscall" +// - ("waiting" ... see below) +// - ("dead" ... these goroutines should never appear in dumps) +// - "copystack" +// - "preempted" +// - ("???" ... something IS severely broken.) +// In case a goroutine is in waiting state, the State field instead starts with +// one of the following strings, never showing a lonely "waiting" string, but +// rather one of the reasons for waiting: +// - "chan receive" +// - "chan send" +// - "select" +// - "sleep" +// - "finalizer wait" +// - ...quite some more waiting states. +// +// The State description may next contain "(scan)", separated by a single blank +// from the preceding goroutine state text. +// +// If a goroutine is blocked from more than at least a minute, then the state +// description next contains the string "X minutes", where X is the number of +// minutes blocked. This text is separated by a "," and a blank from the +// preceding information. +// +// Finally, OS thread-locked goroutines finally contain "locked to thread" in +// their State description, again separated by a "," and a blank from the +// preceding information. +// +// Please note that the State field never contains the opening and closing +// square brackets as used in plain stack dumps. +type Goroutine struct { + ID uint64 // unique goroutine ID ("goid" in Go's runtime parlance) + State string // goroutine state, such as "running" + TopFunction string // topmost function on goroutine's stack + CreatorFunction string // name of function creating this goroutine, if any + BornAt string // location where the goroutine was started from, if any; format "file-path:line-number" + Backtrace string // goroutine's backtrace (of the stack) +} + +// String returns a short textual description of this goroutine, but without the +// potentially lengthy and ugly backtrace details. +func (g Goroutine) String() string { + s := fmt.Sprintf("Goroutine ID: %d, state: %s, top function: %s", + g.ID, g.State, g.TopFunction) + if g.CreatorFunction == "" { + return s + } + s += fmt.Sprintf(", created by: %s, at: %s", + g.CreatorFunction, g.BornAt) + return s +} + +// GomegaString returns the Gomega struct representation of a Goroutine, but +// without a potentially rather lengthy backtrace. This Gomega object value +// dumps getting happily truncated as to become more or less useless. +func (g Goroutine) GomegaString() string { + return fmt.Sprintf( + "{ID: %d, State: %q, TopFunction: %q, CreatorFunction: %q, BornAt: %q}", + g.ID, g.State, g.TopFunction, g.CreatorFunction, g.BornAt) +} + +// Goroutines returns information about all goroutines. +func Goroutines() []Goroutine { + return goroutines(true) +} + +// Current returns information about the current goroutine in which it is +// called. Please note that the topmost function name will always be +// runtime.Stack. +func Current() Goroutine { + return goroutines(false)[0] +} + +// goroutines is an internal wrapper around dumping either only the stack of the +// current goroutine of the caller or dumping the stacks of all goroutines, and +// then parsing the dump into separate Goroutine descriptions. +func goroutines(all bool) []Goroutine { + return parseStack(stacks(all)) +} + +// parseStack parses the stack dump of one or multiple goroutines, as returned +// by runtime.Stack() and then returns a list of Goroutine descriptions based on +// the dump. +func parseStack(stacks []byte) []Goroutine { + gs := []Goroutine{} + r := bufio.NewReader(bytes.NewReader(stacks)) + for { + // We expect a line describing a new "goroutine", everything else is a + // failure. And yes, if we get an EOF already with this line, bail out. + line, err := r.ReadString('\n') + if err == io.EOF { + break + } + g := new(line) + // Read the rest ... that is, the backtrace for this goroutine. + g.TopFunction, g.Backtrace = parseGoroutineBacktrace(r) + if strings.HasSuffix(g.Backtrace, "\n\n") { + g.Backtrace = g.Backtrace[:len(g.Backtrace)-1] + } + g.CreatorFunction, g.BornAt = findCreator(g.Backtrace) + gs = append(gs, g) + } + return gs +} + +// new takes a goroutine line from a stack dump and returns a Goroutine object +// based on the information contained in the dump. +func new(s string) Goroutine { + s = strings.TrimSuffix(s, ":\n") + fields := strings.SplitN(s, " ", 3) + if len(fields) != 3 { + panic(fmt.Sprintf("invalid stack header: %q", s)) + } + id, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + panic(fmt.Sprintf("invalid stack header ID: %q, header: %q", fields[1], s)) + } + state := strings.TrimSuffix(strings.TrimPrefix(fields[2], "["), "]") + return Goroutine{ID: id, State: state} +} + +// Beginning of line indicating the creator of a Goroutine, if any. This +// indication is missing for the main goroutine as it appeared in a big bang or +// something similar. +const backtraceGoroutineCreator = "created by " + +// findCreator solves the great mystery of Gokind, answering the question of who +// created this goroutine? Given a backtrace, that is. +func findCreator(backtrace string) (creator, location string) { + pos := strings.LastIndex(backtrace, backtraceGoroutineCreator) + if pos < 0 { + return + } + // Split the "created by ..." line from the following line giving us the + // (indented) file name:line number and the hex offset of the call location + // within the function. + details := strings.SplitN(backtrace[pos+len(backtraceGoroutineCreator):], "\n", 3) + if len(details) < 2 { + return + } + // Split off the call location hex offset which is of no use to us, and only + // keep the file path and line number information. This will be useful for + // diagnosis, when dumping leaked goroutines. + offsetpos := strings.LastIndex(details[1], " +0x") + if offsetpos < 0 { + return + } + location = strings.TrimSpace(details[1][:offsetpos]) + creator = details[0] + return +} + +// Beginning of header line introducing a (new) goroutine in a backtrace. +const backtraceGoroutineHeader = "goroutine " + +// Length of the header line prefix introducing a (new) goroutine in a +// backtrace. +const backtraceGoroutineHeaderLen = len(backtraceGoroutineHeader) + +// parseGoroutineBacktrace reads from reader r the backtrace information until +// the end or until the next goroutine header is seen. This next goroutine +// header is NOT consumed so that callers can still read the next header from +// the reader. +func parseGoroutineBacktrace(r *bufio.Reader) (topFn string, backtrace string) { + bt := bytes.Buffer{} + // Read backtrace information belonging to this goroutine until we meet + // another goroutine header. + for { + header, err := r.Peek(backtraceGoroutineHeaderLen) + if string(header) == backtraceGoroutineHeader { + // next goroutine header is up for read, so we're done with parsing + // the backtrace of this goroutine. + break + } + if err != nil && err != io.EOF { + // There is some serious problem with the stack dump, so we + // decidedly panic now. + panic("parsing backtrace failed: " + err.Error()) + } + line, err := r.ReadString('\n') + if err != nil && err != io.EOF { + // There is some serious problem with the stack dump, so we + // decidedly panic now. + panic("parsing backtrace failed: " + err.Error()) + } + // The first line after a goroutine header lists the "topmost" function. + if topFn == "" { + line := /*sic!*/ strings.TrimSpace(line) + idx := strings.LastIndex(line, "(") + if idx <= 0 { + panic(fmt.Sprintf("invalid function call stack entry: %q", line)) + } + topFn = line[:idx] + } + // Always append the line read to the goroutine's backtrace. + bt.WriteString(line) + if err == io.EOF { + // we're reached the end of the stack dump, so that's it. + break + } + } + return topFn, bt.String() +} diff --git a/gleak/goroutine/goroutine_suite_test.go b/gleak/goroutine/goroutine_suite_test.go new file mode 100644 index 000000000..f0fe4be76 --- /dev/null +++ b/gleak/goroutine/goroutine_suite_test.go @@ -0,0 +1,13 @@ +package goroutine + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGoroutine(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Goroutine Suite") +} diff --git a/gleak/goroutine/goroutine_test.go b/gleak/goroutine/goroutine_test.go new file mode 100644 index 000000000..e0e3e960b --- /dev/null +++ b/gleak/goroutine/goroutine_test.go @@ -0,0 +1,241 @@ +package goroutine + +import ( + "bufio" + "errors" + "io" + "reflect" + "strings" + "sync" + "testing/iotest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("goroutine", func() { + + const stack = `runtime/debug.Stack() + /usr/local/go-faketime/src/runtime/debug/stack.go:24 +0x65 +runtime/debug.PrintStack() + /usr/local/go-faketime/src/runtime/debug/stack.go:16 +0x19 +main.main() + /tmp/sandbox3386995578/prog.go:10 +0x17 +` + const header = `goroutine 666 [running]: +` + const nextStack = header + `main.hades() + /tmp/sandbox3386995578/prog.go:10 +0x17 +` + + It("prints", func() { + Expect(Goroutine{ + ID: 1234, + State: "gone", + TopFunction: "gopher.hole", + }.String()).To(Equal( + "Goroutine ID: 1234, state: gone, top function: gopher.hole")) + + Expect(Goroutine{ + ID: 1234, + State: "gone", + TopFunction: "gopher.hole", + CreatorFunction: "google", + BornAt: "/plan/10:2009", + }.String()).To(Equal( + "Goroutine ID: 1234, state: gone, top function: gopher.hole, created by: google, at: /plan/10:2009")) + + Expect(Goroutine{ + ID: 1234, + State: "gone", + TopFunction: "gopher.hole", + CreatorFunction: "google", + BornAt: "/plan/10:2009", + }.GomegaString()).To(Equal( + "{ID: 1234, State: \"gone\", TopFunction: \"gopher.hole\", CreatorFunction: \"google\", BornAt: \"/plan/10:2009\"}")) + }) + + Context("goroutine header", func() { + + It("parses goroutine header", func() { + g := new(header) + Expect(g.ID).To(Equal(uint64(666))) + Expect(g.State).To(Equal("running")) + }) + + It("panics on malformed goroutine header", func() { + Expect(func() { _ = new("a") }).To(PanicWith(MatchRegexp(`invalid stack header: .*`))) + Expect(func() { _ = new("a b") }).To(PanicWith(MatchRegexp(`invalid stack header: .*`))) + }) + + It("panics on malformed goroutine ID", func() { + Expect(func() { _ = new("a b c:\n") }).To(PanicWith(MatchRegexp(`invalid stack header ID: "b", header: ".*"`))) + }) + + }) + + Context("goroutine backtrace", func() { + + It("parses goroutine's backtrace", func() { + r := bufio.NewReader(strings.NewReader(stack)) + topF, backtrace := parseGoroutineBacktrace(r) + Expect(topF).To(Equal("runtime/debug.Stack")) + Expect(backtrace).To(Equal(stack)) + + r.Reset(strings.NewReader(stack[:len(stack)-1])) + topF, backtrace = parseGoroutineBacktrace(r) + Expect(topF).To(Equal("runtime/debug.Stack")) + Expect(backtrace).To(Equal(stack[:len(stack)-1])) + }) + + It("parses goroutine's backtrace until next goroutine header", func() { + r := bufio.NewReader(strings.NewReader(stack + nextStack)) + topF, backtrace := parseGoroutineBacktrace(r) + Expect(topF).To(Equal("runtime/debug.Stack")) + Expect(backtrace).To(Equal(stack)) + }) + + It("panics on invalid function call stack entry", func() { + r := bufio.NewReader(strings.NewReader(`main.main + /somewhere/prog.go:123 +0x666 + `)) + Expect(func() { parseGoroutineBacktrace(r) }).To(PanicWith(MatchRegexp(`invalid function call stack entry: "main.main"`))) + }) + + It("panics on failing reader", func() { + Expect(func() { + parseGoroutineBacktrace(bufio.NewReader( + iotest.ErrReader(errors.New("foo failure")))) + }).To(PanicWith("parsing backtrace failed: foo failure")) + + Expect(func() { + parseGoroutineBacktrace( + bufio.NewReaderSize( + iotest.TimeoutReader(strings.NewReader(strings.Repeat("x", 32))), + 16)) + }).To(PanicWith("parsing backtrace failed: timeout")) + + Expect(func() { + parseGoroutineBacktrace(bufio.NewReader( + iotest.ErrReader(io.ErrClosedPipe))) + }).To(PanicWith(MatchRegexp(`parsing backtrace failed: .*`))) + }) + + It("parses goroutine information and stack", func() { + gs := parseStack([]byte(header + stack)) + Expect(gs).To(HaveLen(1)) + Expect(gs[0]).To(And( + HaveField("ID", uint64(666)), + HaveField("State", "running"), + HaveField("TopFunction", "runtime/debug.Stack"), + HaveField("Backtrace", stack))) + }) + + It("finds its Creator", func() { + creator, location := findCreator(` +goroutine 42 [chan receive]: +main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo + /home/foo/test.go:5 +0x64 +`) + Expect(creator).To(Equal("main.foo")) + Expect(location).To(Equal("/home/foo/test.go:5")) + }) + + It("handles missing or invalid creator information", func() { + creator, location := findCreator("") + Expect(creator).To(BeEmpty()) + Expect(location).To(BeEmpty()) + + creator, location = findCreator(` +goroutine 42 [chan receive]: +main.foo.func1() + /home/foo/test.go:6 +0x28 +created by`) + Expect(creator).To(BeEmpty()) + Expect(location).To(BeEmpty()) + + creator, location = findCreator(` +goroutine 42 [chan receive]: +main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo`) + Expect(creator).To(BeEmpty()) + Expect(location).To(BeEmpty()) + + creator, location = findCreator(` +goroutine 42 [chan receive]: +main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo + /home/foo/test.go:5 +`) + Expect(creator).To(BeEmpty()) + Expect(location).To(BeEmpty()) + }) + + }) + + Context("live", func() { + + It("discovers current goroutine information", func() { + type T struct{} + pkg := reflect.TypeOf(T{}).PkgPath() + gs := goroutines(false) + Expect(gs).To(HaveLen(1)) + Expect(gs[0]).To(And( + HaveField("ID", Not(BeZero())), + HaveField("State", "running"), + HaveField("TopFunction", pkg+".stacks"), + HaveField("Backtrace", MatchRegexp(pkg+`.stacks.* +`)))) + }) + + It("discovers a goroutine's creator", func() { + ch := make(chan Goroutine) + go func() { + ch <- Current() + }() + g := <-ch + Expect(g.CreatorFunction).NotTo(BeEmpty(), "no creator: %s", g.Backtrace) + Expect(g.BornAt).NotTo(BeEmpty()) + }) + + It("discovers all goroutine information", func() { + By("creating a chan receive canary goroutine") + done := make(chan struct{}) + go testWait(done) + once := sync.Once{} + cloze := func() { once.Do(func() { close(done) }) } + defer cloze() + + By("getting all goroutines including canary") + type T struct{} + pkg := reflect.TypeOf(T{}).PkgPath() + Eventually(Goroutines). + WithTimeout(1 * time.Second).WithPolling(250 * time.Millisecond). + Should(ContainElements( + And( + HaveField("TopFunction", pkg+".stacks"), + HaveField("State", "running")), + And( + HaveField("TopFunction", pkg+".testWait"), + HaveField("State", "chan receive")), + )) + + By("getting all goroutines after being done with the canary") + cloze() + Eventually(Goroutines). + WithTimeout(1 * time.Second).WithPolling(250 * time.Millisecond). + ShouldNot(ContainElement(HaveField("TopFunction", pkg+".testWait"))) + }) + + }) + +}) + +func testWait(done <-chan struct{}) { + <-done +} diff --git a/gleak/goroutine/stack.go b/gleak/goroutine/stack.go new file mode 100644 index 000000000..e9569f936 --- /dev/null +++ b/gleak/goroutine/stack.go @@ -0,0 +1,17 @@ +package goroutine + +import "runtime" + +const startStackBufferSize = 64 * 1024 // 64kB + +// stacks returns stack trace information for either all goroutines or only the +// current goroutine. It is a convenience wrapper around runtime.Stack, hiding +// the result allocation. +func stacks(all bool) []byte { + for size := startStackBufferSize; ; size *= 2 { + buffer := make([]byte, size) + if n := runtime.Stack(buffer, all); n < size { + return buffer[:n] + } + } +} diff --git a/gleak/goroutines.go b/gleak/goroutines.go new file mode 100644 index 000000000..ace39b863 --- /dev/null +++ b/gleak/goroutines.go @@ -0,0 +1,10 @@ +package gleak + +import "github.com/onsi/gomega/gleak/goroutine" + +// Goroutines returns information about all goroutines: their goroutine IDs, the +// names of the topmost functions in the backtraces, and finally the goroutine +// backtraces. +func Goroutines() []goroutine.Goroutine { + return goroutine.Goroutines() +} diff --git a/gleak/goroutines_test.go b/gleak/goroutines_test.go new file mode 100644 index 000000000..085462de8 --- /dev/null +++ b/gleak/goroutines_test.go @@ -0,0 +1,17 @@ +package gleak + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("goroutines", func() { + + It("returns all goroutines", func() { + Expect(Goroutines()).To(ContainElement( + HaveField("TopFunction", SatisfyAny( + Equal("testing.(*T).Run"), + Equal("testing.RunTests"))))) + }) + +}) diff --git a/gleak/have_leaked_matcher.go b/gleak/have_leaked_matcher.go new file mode 100644 index 000000000..dd6f7ead6 --- /dev/null +++ b/gleak/have_leaked_matcher.go @@ -0,0 +1,303 @@ +package gleak + +import ( + "fmt" + "path" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/gleak/goroutine" + "github.com/onsi/gomega/types" +) + +// ReportFilenameWithPath controls whether to show call locations in leak +// reports by default in abbreviated form with only source code filename with +// package name and line number, or alternatively with source code filename with +// path and line number. +// +// That is, with ReportFilenameWithPath==false: +// +// foo/bar.go:123 +// +// Or with ReportFilenameWithPath==true: +// +// /home/goworld/coolprojects/mymodule/foo/bar.go:123 +var ReportFilenameWithPath = false + +// standardFilters specifies the always automatically included no-leak goroutine +// filter matchers. +// +// Note: it's okay to instantiate the Gomega Matchers here, as all goroutine +// filtering-related gleak matchers are stateless with respect to any actual +// value they try to match. This allows us to simply prepend them to any +// user-supplied optional matchers when HaveLeaked returns a new goroutine +// leakage detecting matcher. +// +// Note: cgo's goroutines with status "[syscall, locked to thread]" do not +// appear any longer (since mid-2017), as these cgo goroutines are put into the +// "dead" state when not in use. See: https://github.com/golang/go/issues/16714 +// and https://go-review.googlesource.com/c/go/+/45030/. +var standardFilters = []types.GomegaMatcher{ + // Ginkgo testing framework + IgnoringTopFunction("github.com/onsi/ginkgo/v2/internal.(*Suite).runNode"), + IgnoringTopFunction("github.com/onsi/ginkgo/v2/internal.(*Suite).runNode..."), + IgnoringTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts..."), + IgnoringTopFunction("github.com/onsi/ginkgo/internal/specrunner.(*SpecRunner).registerForInterrupts"), + + // goroutines of Go's own testing package for its own workings... + IgnoringTopFunction("testing.RunTests [chan receive]"), + IgnoringTopFunction("testing.(*T).Run [chan receive]"), + IgnoringTopFunction("testing.(*T).Parallel [chan receive]"), + + // os/signal starts its own runtime goroutine, where loop calls signal_recv + // in a loop, so we need to expect them both... + IgnoringTopFunction("os/signal.signal_recv"), + IgnoringTopFunction("os/signal.loop"), + + // signal.Notify starts a runtime goroutine... + IgnoringInBacktrace("runtime.ensureSigM"), + + // reading a trace... + IgnoringInBacktrace("runtime.ReadTrace"), +} + +// HaveLeaked succeeds (or rather, "suckceeds" considering it appears in failing +// tests) if after filtering out ("ignoring") the expected goroutines from the +// list of actual goroutines the remaining list of goroutines is non-empty. +// These goroutines not filtered out are considered to have been leaked. +// +// For convenience, HaveLeaked automatically filters out well-known runtime and +// testing goroutines using a built-in standard filter matchers list. In +// addition to the built-in filters, HaveLeaked accepts an optional list of +// non-leaky goroutine filter matchers. These filtering matchers can be +// specified in different formats, as described below. +// +// Since there might be "pending" goroutines at the end of tests that eventually +// will properly wind down so they aren't leaking, HaveLeaked is best paired +// with Eventually instead of Expect. In its shortest form this will use +// Eventually's default timeout and polling interval settings, but these can be +// overridden as usual: +// +// // Remember to use "Goroutines" and not "Goroutines()" with Eventually()! +// Eventually(Goroutines).ShouldNot(HaveLeaked()) +// Eventually(Goroutines).WithTimeout(5 * time.Second).ShouldNot(HaveLeaked()) +// +// In its simplest form, an expected non-leaky goroutine can be identified by +// passing the (fully qualified) name (in form of a string) of the topmost +// function in the backtrace. For instance: +// +// Eventually(Goroutines).ShouldNot(HaveLeaked("foo.bar")) +// +// This is the shorthand equivalent to this explicit form: +// +// Eventually(Goroutines).ShouldNot(HaveLeaked(IgnoringTopFunction("foo.bar"))) +// +// HaveLeak also accepts passing a slice of Goroutine objects to be considered +// non-leaky goroutines. +// +// snapshot := Goroutines() +// DoSomething() +// Eventually(Goroutines).ShouldNot(HaveLeaked(snapshot)) +// +// Again, this is shorthand for the following explicit form: +// +// snapshot := Goroutines() +// DoSomething() +// Eventually(Goroutines).ShouldNot(HaveLeaked(IgnoringGoroutines(snapshot))) +// +// Finally, HaveLeaked accepts any GomegaMatcher and will repeatedly pass it a +// Goroutine object: if the matcher succeeds, the Goroutine object in question +// is considered to be non-leaked and thus filtered out. While the following +// built-in Goroutine filter matchers should hopefully cover most situations, +// any suitable GomegaMatcher can be used for tricky leaky Goroutine filtering. +// +// IgnoringTopFunction("foo.bar") +// IgnoringTopFunction("foo.bar...") +// IgnoringTopFunction("foo.bar [chan receive]") +// IgnoringGoroutines(expectedGoroutines) +// IgnoringInBacktrace("foo.bar.baz") +func HaveLeaked(ignoring ...interface{}) types.GomegaMatcher { + m := &HaveLeakedMatcher{filters: standardFilters} + for _, ign := range ignoring { + switch ign := ign.(type) { + case string: + m.filters = append(m.filters, IgnoringTopFunction(ign)) + case []goroutine.Goroutine: + m.filters = append(m.filters, IgnoringGoroutines(ign)) + case types.GomegaMatcher: + m.filters = append(m.filters, ign) + default: + panic(fmt.Sprintf("HaveLeaked expected a string, []Goroutine, or GomegaMatcher, but got:\n%s", format.Object(ign, 1))) + } + } + return m +} + +// HaveLeakedMatcher implements the HaveLeaked Gomega Matcher that succeeds if +// the actual list of goroutines is non-empty after filtering out the expected +// goroutines. +type HaveLeakedMatcher struct { + filters []types.GomegaMatcher // expected goroutines that aren't leaks. + leaked []goroutine.Goroutine // surplus goroutines which we consider to be leaks. +} + +var gsT = reflect.TypeOf([]goroutine.Goroutine{}) + +// Match succeeds if actual is an array or slice of goroutine.Goroutine +// information and still contains goroutines after filtering out all expected +// goroutines that were specified when creating the matcher. +func (matcher *HaveLeakedMatcher) Match(actual interface{}) (success bool, err error) { + val := reflect.ValueOf(actual) + switch val.Kind() { + case reflect.Array, reflect.Slice: + if !val.Type().AssignableTo(gsT) { + return false, fmt.Errorf( + "HaveLeaked matcher expects an array or slice of goroutines. Got:\n%s", + format.Object(actual, 1)) + } + default: + return false, fmt.Errorf( + "HaveLeaked matcher expects an array or slice of goroutines. Got:\n%s", + format.Object(actual, 1)) + } + goroutines := val.Convert(gsT).Interface().([]goroutine.Goroutine) + matcher.leaked, err = matcher.filter(goroutines, matcher.filters) + if err != nil { + return false, err + } + if len(matcher.leaked) == 0 { + return false, nil + } + return true, nil // we have leak(ed) +} + +// FailureMessage returns a failure message if there are leaked goroutines. +func (matcher *HaveLeakedMatcher) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected to leak %d goroutines:\n%s", len(matcher.leaked), matcher.listGoroutines(matcher.leaked, 1)) +} + +// NegatedFailureMessage returns a negated failure message if there aren't any leaked goroutines. +func (matcher *HaveLeakedMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("Expected not to leak %d goroutines:\n%s", len(matcher.leaked), matcher.listGoroutines(matcher.leaked, 1)) +} + +// listGoroutines returns a somewhat compact textual representation of the +// specified goroutines, by ignoring the often quite lengthy backtrace +// information. +func (matcher *HaveLeakedMatcher) listGoroutines(gs []goroutine.Goroutine, indentation uint) string { + var buff strings.Builder + indent := strings.Repeat(format.Indent, int(indentation)) + backtraceIdent := strings.Repeat(format.Indent, int(indentation+1)) + for gidx, g := range gs { + if gidx > 0 { + buff.WriteRune('\n') + } + buff.WriteString(indent) + buff.WriteString("goroutine ") + buff.WriteString(strconv.FormatUint(g.ID, 10)) + buff.WriteString(" [") + buff.WriteString(g.State) + buff.WriteString("]\n") + + backtrace := g.Backtrace + for backtrace != "" { + buff.WriteString(backtraceIdent) + // take the next two lines (function name and file name plus line + // number) and output them as a single indented line. + nlIdx := strings.IndexRune(backtrace, '\n') + if nlIdx < 0 { + // ...a dodgy single line + buff.WriteString(backtrace) + break + } + calledFuncName := backtrace[:nlIdx] + // Take care of not mangling the optional "created by " prefix is + // present, when formatting the location to use either long or + // shortened filenames and paths. + location := backtrace[nlIdx+1:] + nnlIdx := strings.IndexRune(location, '\n') + if nnlIdx >= 0 { + backtrace, location = location[nnlIdx+1:], location[:nnlIdx] + } else { + backtrace = "" // ...the next location line is missing + } + // Don't accidentally strip off the "created by" prefix when + // shortening the call site location filename... + location = strings.TrimSpace(location) // strip of indentation + lineno := "" + if linenoIdx := strings.LastIndex(location, ":"); linenoIdx >= 0 { + location, lineno = location[:linenoIdx], location[linenoIdx+1:] + } + location = formatFilename(location) + ":" + lineno + // Add to compact backtrace + buff.WriteString(calledFuncName) + buff.WriteString(" at ") + // Don't output any program counter hex offsets, so strip them out + // here, if present; well, they should always be present, but better + // safe than sorry. + if offsetIdx := strings.LastIndexFunc(location, + func(r rune) bool { return r == ' ' }); offsetIdx >= 0 { + buff.WriteString(location[:offsetIdx]) + } else { + buff.WriteString(location) + } + if backtrace != "" { + buff.WriteRune('\n') + } + } + } + return buff.String() +} + +// filter returns a list of leaked goroutines by removing all expected +// goroutines from the given list of goroutines, using the specified checkers. +// The calling goroutine is always filtered out automatically. A checker checks +// if a certain goroutine is expected (then it gets filtered out), or not. If +// all checkers do not signal that they expect a certain goroutine then this +// goroutine is considered to be a leak. +func (matcher *HaveLeakedMatcher) filter( + goroutines []goroutine.Goroutine, filters []types.GomegaMatcher, +) ([]goroutine.Goroutine, error) { + gs := make([]goroutine.Goroutine, 0, len(goroutines)) + myID := goroutine.Current().ID +nextgoroutine: + for _, g := range goroutines { + if g.ID == myID { + continue + } + for _, filter := range filters { + matches, err := filter.Match(g) + if err != nil { + return nil, err + } + if matches { + continue nextgoroutine + } + } + gs = append(gs, g) + } + return gs, nil +} + +// formatFilename takes the ReportFilenameWithPath setting into account to +// either return the full specified filename with a path or alternatively +// shortening it to contain only the package name and the filename, but not the +// full path. +func formatFilename(filename string) string { + if ReportFilenameWithPath { + return filename + } + dir := filepath.Dir(filename) + pkg := filepath.Base(dir) + switch pkg { + case ".", "..", "/", "\\": + pkg = "" + } + // Go dumps stacks always with file locations containing forward slashes, + // even on Windows. Thus, we do NOT use filepath.Join here, but instead + // path.Join in order to keep with using forward slashes. + return path.Join(pkg, filepath.ToSlash(filepath.Base(filename))) +} diff --git a/gleak/have_leaked_matcher_test.go b/gleak/have_leaked_matcher_test.go new file mode 100644 index 000000000..7d5d0ce65 --- /dev/null +++ b/gleak/have_leaked_matcher_test.go @@ -0,0 +1,279 @@ +package gleak + +import ( + "os" + "os/signal" + "sync" + "time" + + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Note: Go's stack dumps (backtraces) always contain forward slashes, even on +// Windows. The following tests thus work the same both on *nix and Windows. + +var _ = Describe("HaveLeaked", func() { + + It("renders indented goroutine information including (malformed) backtrace", func() { + gs := []goroutine.Goroutine{ + { + ID: 42, + State: "stoned", + Backtrace: `main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo + /home/foo/test.go:5 +0x64 +`, + }, + } + m := HaveLeaked().(*HaveLeakedMatcher) + Expect(m.listGoroutines(gs, 1)).To(Equal(` goroutine 42 [stoned] + main.foo.func1() at foo/test.go:6 + created by main.foo at foo/test.go:5`)) + + gs = []goroutine.Goroutine{ + { + ID: 42, + State: "stoned", + Backtrace: `main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo + /home/foo/test.go:5 +0x64`, + }, + } + Expect(m.listGoroutines(gs, 1)).To(Equal(` goroutine 42 [stoned] + main.foo.func1() at foo/test.go:6 + created by main.foo at foo/test.go:5`)) + + gs = []goroutine.Goroutine{ + { + ID: 42, + State: "stoned", + Backtrace: `main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo + /home/foo/test.go:5`, + }, + } + Expect(m.listGoroutines(gs, 1)).To(Equal(` goroutine 42 [stoned] + main.foo.func1() at foo/test.go:6 + created by main.foo at foo/test.go:5`)) + + gs = []goroutine.Goroutine{ + { + ID: 42, + State: "stoned", + Backtrace: `main.foo.func1() + /home/foo/test.go:6 +0x28 +created by main.foo`, + }, + } + Expect(m.listGoroutines(gs, 1)).To(Equal(` goroutine 42 [stoned] + main.foo.func1() at foo/test.go:6 + created by main.foo`)) + }) + + It("considers testing and runtime goroutines not to be leaks", func() { + Expect(Goroutines()).NotTo(HaveLeaked(), "should not find any leaks by default") + }) + + When("using signals", func() { + + It("doesn't find leaks", func() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + Eventually(Goroutines).WithTimeout(2*time.Second).WithPolling(250*time.Millisecond). + ShouldNot(HaveLeaked(), "found signal.Notify leaks") + + signal.Reset(os.Interrupt) + Eventually(Goroutines).WithTimeout(2*time.Second).WithPolling(250*time.Millisecond). + ShouldNot(HaveLeaked(), "found signal.Reset leaks") + }) + + }) + + It("checks against list of expected goroutines", func() { + By("taking a snapshot") + gs := Goroutines() + m := HaveLeaked(gs) + + By("starting a goroutine") + done := make(chan struct{}) + var once sync.Once + go func() { + <-done + }() + defer once.Do(func() { close(done) }) + + By("detecting the goroutine") + Expect(m.Match(Goroutines())).To(BeTrue()) + + By("terminating the goroutine and ensuring it has terminated") + once.Do(func() { close(done) }) + Eventually(func() (bool, error) { + return m.Match(Goroutines()) + }).Should(BeFalse()) + }) + + Context("failure messages", func() { + + var snapshot []goroutine.Goroutine + + BeforeEach(func() { + snapshot = Goroutines() + done := make(chan struct{}) + go func() { + <-done + }() + DeferCleanup(func() { + close(done) + Eventually(Goroutines).ShouldNot(HaveLeaked(snapshot)) + }) + }) + + It("returns a failure message", func() { + m := HaveLeaked(snapshot) + gs := Goroutines() + Expect(m.Match(gs)).To(BeTrue()) + Expect(m.FailureMessage(gs)).To(MatchRegexp(`Expected to leak 1 goroutines: + goroutine \d+ \[.+\] + .* at .*:\d+ + created by .* at .*:\d+`)) + }) + + It("returns a negated failure message", func() { + m := HaveLeaked(snapshot) + gs := Goroutines() + Expect(m.Match(gs)).To(BeTrue()) + Expect(m.NegatedFailureMessage(gs)).To(MatchRegexp(`Expected not to leak 1 goroutines: + goroutine \d+ \[.+\] + .* at .*:\d+ + created by .* at .*:\d+`)) + }) + + When("things go wrong", func() { + + It("rejects unsupported filter args types", func() { + Expect(func() { _ = HaveLeaked(42) }).To(PanicWith( + "HaveLeaked expected a string, []Goroutine, or GomegaMatcher, but got:\n : 42")) + }) + + It("accepts plain strings as filters", func() { + m := HaveLeaked("foo.bar") + Expect(m.Match([]goroutine.Goroutine{ + {TopFunction: "foo.bar"}, + })).To(BeFalse()) + }) + + It("expects actual to be a slice of goroutine.Goroutine", func() { + m := HaveLeaked() + Expect(m.Match(nil)).Error().To(MatchError( + "HaveLeaked matcher expects an array or slice of goroutines. Got:\n : nil")) + Expect(m.Match("foo!")).Error().To(MatchError( + "HaveLeaked matcher expects an array or slice of goroutines. Got:\n : foo!")) + Expect(m.Match([]string{"foo!"})).Error().To(MatchError( + "HaveLeaked matcher expects an array or slice of goroutines. Got:\n <[]string | len:1, cap:1>: [\"foo!\"]")) + }) + + It("handles filter matcher errors", func() { + m := HaveLeaked(HaveField("foobar", BeNil())) + Expect(m.Match([]goroutine.Goroutine{ + {ID: 0}, + })).Error().To(HaveOccurred()) + }) + + }) + + }) + + Context("wrapped around test nodes", func() { + + var snapshot []goroutine.Goroutine + + When("not leaking", func() { + + BeforeEach(func() { + snapshot = Goroutines() + }) + + AfterEach(func() { + Eventually(Goroutines).ShouldNot(HaveLeaked(snapshot)) + }) + + It("doesn't leak in test", func() { + // nothing + }) + + }) + + When("leaking", func() { + + done := make(chan struct{}) + + BeforeEach(func() { + snapshot = Goroutines() + }) + + AfterEach(func() { + Expect(Goroutines()).To(HaveLeaked(snapshot)) + close(done) + Eventually(Goroutines).ShouldNot(HaveLeaked(snapshot)) + }) + + It("leaks in test", func() { + go func() { + <-done + }() + }) + + }) + + }) + + Context("handling file names and paths in backtraces", func() { + + When("ReportFilenameWithPath is true", Ordered, func() { + + var oldState bool + + BeforeAll(func() { + oldState = ReportFilenameWithPath + ReportFilenameWithPath = true + DeferCleanup(func() { + ReportFilenameWithPath = oldState + }) + }) + + It("doesn't shorten filenames", func() { + Expect(formatFilename("/home/foo/bar/baz.go")).To(Equal("/home/foo/bar/baz.go")) + }) + + }) + + When("ReportFilenameWithPath is false", Ordered, func() { + + var oldState bool + + BeforeAll(func() { + oldState = ReportFilenameWithPath + ReportFilenameWithPath = false + DeferCleanup(func() { + ReportFilenameWithPath = oldState + }) + }) + + It("does return only package and filename, but no path", func() { + Expect(formatFilename("/home/foo/bar/baz.go")).To(Equal("bar/baz.go")) + Expect(formatFilename("/bar/baz.go")).To(Equal("bar/baz.go")) + Expect(formatFilename("/baz.go")).To(Equal("baz.go")) + Expect(formatFilename("/")).To(Equal("/")) + }) + + }) + + }) + +}) diff --git a/gleak/ignoring_creator.go b/gleak/ignoring_creator.go new file mode 100644 index 000000000..66bc45549 --- /dev/null +++ b/gleak/ignoring_creator.go @@ -0,0 +1,69 @@ +package gleak + +import ( + "fmt" + "strings" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +// IgnoringCreator succeeds if the goroutine was created by a function matching +// the specified name. The expected creator function name is either in the form +// of "creatorfunction-name" or "creatorfunction-name...". +// +// An ellipsis "..." after a creatorfunction-name matches any creator function +// name if creatorfunction-name is a prefix and the goroutine's creator function +// name is at least one level deeper. For instance, "foo.bar..." matches +// "foo.bar.baz", but doesn't match "foo.bar". +func IgnoringCreator(creatorfname string) types.GomegaMatcher { + if strings.HasSuffix(creatorfname, "...") { + expectedCreatorFunction := creatorfname[:len(creatorfname)-3+1] // ...one trailing dot still expected + return &ignoringCreator{ + expectedCreatorFunction: expectedCreatorFunction, + matchPrefix: true, + } + } + return &ignoringCreator{ + expectedCreatorFunction: creatorfname, + } +} + +type ignoringCreator struct { + expectedCreatorFunction string + matchPrefix bool +} + +// Match succeeds if an actual goroutine's creator function in the backtrace +// matches the specified function name or function name prefix. +func (matcher *ignoringCreator) Match(actual interface{}) (success bool, err error) { + g, err := G(actual, "IgnoringCreator") + if err != nil { + return false, err + } + if matcher.matchPrefix { + return strings.HasPrefix(g.CreatorFunction, matcher.expectedCreatorFunction), nil + } + return g.CreatorFunction == matcher.expectedCreatorFunction, nil +} + +// FailureMessage returns a failure message if the actual goroutine doesn't have +// the specified function name/prefix (and optional state) at the top of the +// backtrace. +func (matcher *ignoringCreator) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, matcher.message()) +} + +// NegatedFailureMessage returns a failure message if the actual goroutine has +// the specified function name/prefix (and optional state) at the top of the +// backtrace. +func (matcher *ignoringCreator) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, "not "+matcher.message()) +} + +func (matcher *ignoringCreator) message() string { + if matcher.matchPrefix { + return fmt.Sprintf("to be created by a function with prefix %q", matcher.expectedCreatorFunction) + } + return fmt.Sprintf("to be created by %q", matcher.expectedCreatorFunction) +} diff --git a/gleak/ignoring_creator_test.go b/gleak/ignoring_creator_test.go new file mode 100644 index 000000000..8c4eabbdb --- /dev/null +++ b/gleak/ignoring_creator_test.go @@ -0,0 +1,60 @@ +package gleak + +import ( + "reflect" + + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func creator() goroutine.Goroutine { + ch := make(chan goroutine.Goroutine) + go func() { + ch <- goroutine.Current() + }() + return <-ch +} + +var _ = Describe("IgnoringCreator matcher", func() { + + It("returns an error for an invalid actual", func() { + m := IgnoringCreator("foo.bar") + Expect(m.Match(nil)).Error().To(MatchError("IgnoringCreator matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : nil")) + }) + + It("matches a creator function by full name", func() { + type T struct{} + pkg := reflect.TypeOf(T{}).PkgPath() + m := IgnoringCreator(pkg + ".creator") + g := creator() + Expect(m.Match(g)).To(BeTrue(), "creator %v", g.String()) + Expect(m.Match(goroutine.Current())).To(BeFalse()) + }) + + It("matches a toplevel function by prefix", func() { + type T struct{} + pkg := reflect.TypeOf(T{}).PkgPath() + m := IgnoringCreator(pkg + "...") + g := creator() + Expect(m.Match(g)).To(BeTrue(), "creator %v", g.String()) + Expect(m.Match(goroutine.Current())).To(BeFalse()) + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "spanish.inquisition", + })).To(BeFalse()) + }) + + It("returns failure messages", func() { + m := IgnoringCreator("foo.bar") + Expect(m.FailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nto be created by \"foo.bar\"")) + Expect(m.NegatedFailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nnot to be created by \"foo.bar\"")) + + m = IgnoringCreator("foo...") + Expect(m.FailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nto be created by a function with prefix \"foo.\"")) + }) + +}) diff --git a/gleak/ignoring_goroutines.go b/gleak/ignoring_goroutines.go new file mode 100644 index 000000000..68e1044c1 --- /dev/null +++ b/gleak/ignoring_goroutines.go @@ -0,0 +1,62 @@ +package gleak + +import ( + "sort" + + "github.com/onsi/gomega/gleak/goroutine" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +// IgnoringGoroutines succeeds if an actual goroutine, identified by its ID, is +// in a slice of expected goroutines. A typical use of the IgnoringGoroutines +// matcher is to take a snapshot of the current goroutines just right before a +// test and then at the end of a test filtering out these "good" and known +// goroutines. +func IgnoringGoroutines(goroutines []goroutine.Goroutine) types.GomegaMatcher { + m := &ignoringGoroutinesMatcher{ + ignoreGoids: map[uint64]struct{}{}, + } + for _, g := range goroutines { + m.ignoreGoids[g.ID] = struct{}{} + } + return m +} + +type ignoringGoroutinesMatcher struct { + ignoreGoids map[uint64]struct{} +} + +// Match succeeds if actual is a goroutine.Goroutine and its ID is in the set of +// goroutine IDs to expect and thus to ignore in leak checks. +func (matcher *ignoringGoroutinesMatcher) Match(actual interface{}) (success bool, err error) { + g, err := G(actual, "IgnoringGoroutines") + if err != nil { + return false, err + } + _, ok := matcher.ignoreGoids[g.ID] + return ok, nil +} + +// FailureMessage returns a failure message if the actual goroutine isn't in the +// set of goroutines to be ignored. +func (matcher *ignoringGoroutinesMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, "to be contained in the list of expected goroutine IDs", matcher.expectedGoids()) +} + +// NegatedFailureMessage returns a negated failure message if the actual +// goroutine actually is in the set of goroutines to be ignored. +func (matcher *ignoringGoroutinesMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, "not to be contained in the list of expected goroutine IDs", matcher.expectedGoids()) +} + +// expectedGoids returns the sorted list of expected goroutine IDs. +func (matcher *ignoringGoroutinesMatcher) expectedGoids() []uint64 { + ids := make([]uint64, 0, len(matcher.ignoreGoids)) + for id := range matcher.ignoreGoids { + ids = append(ids, id) + } + sort.Sort(Uint64Slice(ids)) + return ids +} diff --git a/gleak/ignoring_goroutines_test.go b/gleak/ignoring_goroutines_test.go new file mode 100644 index 000000000..52451bb99 --- /dev/null +++ b/gleak/ignoring_goroutines_test.go @@ -0,0 +1,35 @@ +package gleak + +import ( + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IgnoringGoroutines matcher", func() { + + It("returns an error for an invalid actual", func() { + m := IgnoringGoroutines(Goroutines()) + Expect(m.Match(nil)).Error().To(MatchError( + "IgnoringGoroutines matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : nil")) + }) + + It("matches", func() { + gs := Goroutines() + me := gs[0] + m := IgnoringGoroutines(gs) + Expect(m.Match(me)).To(BeTrue()) + Expect(m.Match(gs[1])).To(BeTrue()) + Expect(m.Match(goroutine.Goroutine{})).To(BeFalse()) + }) + + It("returns failure messages", func() { + m := IgnoringGoroutines(Goroutines()) + Expect(m.FailureMessage(goroutine.Goroutine{})).To(MatchRegexp( + `Expected\n : {ID: 0, State: "", TopFunction: "", CreatorFunction: "", BornAt: ""}\nto be contained in the list of expected goroutine IDs\n <\[\]uint64 | len:\d+, cap:\d+>: [.*]`)) + Expect(m.NegatedFailureMessage(goroutine.Goroutine{})).To(MatchRegexp( + `Expected\n : {ID: 0, State: "", TopFunction: "", CreatorFunction: "", BornAt: ""}\nnot to be contained in the list of expected goroutine IDs\n <\[\]uint64 | len:\d+, cap:\d+>: [.*]`)) + }) + +}) diff --git a/gleak/ignoring_in_backtrace.go b/gleak/ignoring_in_backtrace.go new file mode 100644 index 000000000..9372a5ee4 --- /dev/null +++ b/gleak/ignoring_in_backtrace.go @@ -0,0 +1,40 @@ +package gleak + +import ( + "fmt" + "strings" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +// IgnoringInBacktrace succeeds if a function name is contained in the backtrace +// of the actual goroutine description. +func IgnoringInBacktrace(fname string) types.GomegaMatcher { + return &ignoringInBacktraceMatcher{fname: fname} +} + +type ignoringInBacktraceMatcher struct { + fname string +} + +// Match succeeds if actual's backtrace contains the specified function name. +func (matcher *ignoringInBacktraceMatcher) Match(actual interface{}) (success bool, err error) { + g, err := G(actual, "IgnoringInBacktrace") + if err != nil { + return false, err + } + return strings.Contains(g.Backtrace, matcher.fname), nil +} + +// FailureMessage returns a failure message if the actual's backtrace does not +// contain the specified function name. +func (matcher *ignoringInBacktraceMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, fmt.Sprintf("to contain %q in the goroutine's backtrace", matcher.fname)) +} + +// NegatedFailureMessage returns a failure message if the actual's backtrace +// does contain the specified function name. +func (matcher *ignoringInBacktraceMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, fmt.Sprintf("not to contain %q in the goroutine's backtrace", matcher.fname)) +} diff --git a/gleak/ignoring_in_backtrace_test.go b/gleak/ignoring_in_backtrace_test.go new file mode 100644 index 000000000..1b9e5c957 --- /dev/null +++ b/gleak/ignoring_in_backtrace_test.go @@ -0,0 +1,39 @@ +package gleak + +import ( + "reflect" + + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IgnoringInBacktrace matcher", func() { + + It("returns an error for an invalid actual", func() { + m := IgnoringInBacktrace("foo.bar") + Expect(m.Match(nil)).Error().To(MatchError( + "IgnoringInBacktrace matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : nil")) + }) + + It("matches", func() { + type T struct{} + pkg := reflect.TypeOf(T{}).PkgPath() + m := IgnoringInBacktrace(pkg + "/goroutine.stacks") + Expect(m.Match(somefunction())).To(BeTrue()) + }) + + It("returns failure messages", func() { + m := IgnoringInBacktrace("foo.bar") + Expect(m.FailureMessage(goroutine.Goroutine{Backtrace: "abc"})).To(MatchRegexp( + `Expected\n : {ID: 0, State: "", TopFunction: "", CreatorFunction: "", BornAt: ""}\nto contain "foo.bar" in the goroutine's backtrace`)) + Expect(m.NegatedFailureMessage(goroutine.Goroutine{Backtrace: "abc"})).To(MatchRegexp( + `Expected\n : {ID: 0, State: "", TopFunction: "", CreatorFunction: "", BornAt: ""}\nnot to contain "foo.bar" in the goroutine's backtrace`)) + }) + +}) + +func somefunction() goroutine.Goroutine { + return goroutine.Current() +} diff --git a/gleak/ignoring_top_function.go b/gleak/ignoring_top_function.go new file mode 100644 index 000000000..1591e6937 --- /dev/null +++ b/gleak/ignoring_top_function.go @@ -0,0 +1,97 @@ +package gleak + +import ( + "fmt" + "strings" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" +) + +// IgnoringTopFunction succeeds if the topmost function in the backtrace of an +// actual goroutine has the specified function name, and optionally the actual +// goroutine has the specified goroutine state. +// +// The expected top function name topfn is either in the form of +// "topfunction-name", "topfunction-name...", or "topfunction-name [state]". +// +// An ellipsis "..." after a topfunction-name matches any goroutine's top +// function name if topfunction-name is a prefix and the goroutine's top +// function name is at least one level deeper. For instance, "foo.bar..." +// matches "foo.bar.baz", but doesn't match "foo.bar". +// +// If the optional expected state is specified, then a goroutine's state needs +// to start with this expected state text. For instance, "foo.bar [running]" +// matches a goroutine where the name of the top function is "foo.bar" and the +// goroutine's state starts with "running". +func IgnoringTopFunction(topfname string) types.GomegaMatcher { + if brIndex := strings.Index(topfname, "["); brIndex >= 0 { + expectedState := strings.Trim(topfname[brIndex:], "[]") + expectedTopFunction := strings.Trim(topfname[:brIndex], " ") + return &ignoringTopFunctionMatcher{ + expectedTopFunction: expectedTopFunction, + expectedState: expectedState, + } + } + if strings.HasSuffix(topfname, "...") { + expectedTopFunction := topfname[:len(topfname)-3+1] // ...one trailing dot still expected + return &ignoringTopFunctionMatcher{ + expectedTopFunction: expectedTopFunction, + matchPrefix: true, + } + } + return &ignoringTopFunctionMatcher{ + expectedTopFunction: topfname, + } +} + +type ignoringTopFunctionMatcher struct { + expectedTopFunction string + expectedState string + matchPrefix bool +} + +// Match succeeds if an actual goroutine's top function in the backtrace matches +// the specified function name or function name prefix, or name and goroutine +// state. +func (matcher *ignoringTopFunctionMatcher) Match(actual interface{}) (success bool, err error) { + g, err := G(actual, "IgnoringTopFunction") + if err != nil { + return false, err + } + if matcher.matchPrefix { + return strings.HasPrefix(g.TopFunction, matcher.expectedTopFunction), nil + } + if g.TopFunction != matcher.expectedTopFunction { + return false, nil + } + if matcher.expectedState == "" { + return true, nil + } + return strings.HasPrefix(g.State, matcher.expectedState), nil +} + +// FailureMessage returns a failure message if the actual goroutine doesn't have +// the specified function name/prefix (and optional state) at the top of the +// backtrace. +func (matcher *ignoringTopFunctionMatcher) FailureMessage(actual interface{}) (message string) { + return format.Message(actual, matcher.message()) +} + +// NegatedFailureMessage returns a failure message if the actual goroutine has +// the specified function name/prefix (and optional state) at the top of the +// backtrace. +func (matcher *ignoringTopFunctionMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return format.Message(actual, "not "+matcher.message()) +} + +func (matcher *ignoringTopFunctionMatcher) message() string { + if matcher.matchPrefix { + return fmt.Sprintf("to have the prefix %q for its topmost function", matcher.expectedTopFunction) + } + if matcher.expectedState != "" { + return fmt.Sprintf("to have the topmost function %q and the state %q", + matcher.expectedTopFunction, matcher.expectedState) + } + return fmt.Sprintf("to have the topmost function %q", matcher.expectedTopFunction) +} diff --git a/gleak/ignoring_top_function_test.go b/gleak/ignoring_top_function_test.go new file mode 100644 index 000000000..a145f3352 --- /dev/null +++ b/gleak/ignoring_top_function_test.go @@ -0,0 +1,68 @@ +package gleak + +import ( + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IgnoringTopFunction matcher", func() { + + It("returns an error for an invalid actual", func() { + m := IgnoringTopFunction("foo.bar") + Expect(m.Match(nil)).Error().To(MatchError("IgnoringTopFunction matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : nil")) + }) + + It("matches a toplevel function by full name", func() { + m := IgnoringTopFunction("foo.bar") + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "foo.bar", + })).To(BeTrue()) + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "main.main", + })).To(BeFalse()) + }) + + It("matches a toplevel function by prefix", func() { + m := IgnoringTopFunction("foo...") + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "foo.bar", + })).To(BeTrue()) + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "foo", + })).To(BeFalse()) + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "spanish.inquisition", + })).To(BeFalse()) + }) + + It("matches a toplevel function by name and state prefix", func() { + m := IgnoringTopFunction("foo.bar [worried]") + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "foo.bar", + State: "worried, stalled", + })).To(BeTrue()) + Expect(m.Match(goroutine.Goroutine{ + TopFunction: "foo.bar", + State: "uneasy, anxious", + })).To(BeFalse()) + }) + + It("returns failure messages", func() { + m := IgnoringTopFunction("foo.bar") + Expect(m.FailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nto have the topmost function \"foo.bar\"")) + Expect(m.NegatedFailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nnot to have the topmost function \"foo.bar\"")) + + m = IgnoringTopFunction("foo.bar [worried]") + Expect(m.FailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nto have the topmost function \"foo.bar\" and the state \"worried\"")) + + m = IgnoringTopFunction("foo...") + Expect(m.FailureMessage(goroutine.Goroutine{ID: 42, TopFunction: "foo"})).To(Equal( + "Expected\n : {ID: 42, State: \"\", TopFunction: \"foo\", CreatorFunction: \"\", BornAt: \"\"}\nto have the prefix \"foo.\" for its topmost function")) + }) + +}) diff --git a/gleak/util.go b/gleak/util.go new file mode 100644 index 000000000..1c8355784 --- /dev/null +++ b/gleak/util.go @@ -0,0 +1,54 @@ +package gleak + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/gleak/goroutine" +) + +// G takes an actual "any" untyped value and returns it as a typed Goroutine, if +// possible. It returns an error if actual isn't of either type Goroutine or a +// pointer to it. G is intended to be mainly used by goroutine-related Gomega +// matchers, such as IgnoringTopFunction, et cetera. +func G(actual interface{}, matchername string) (goroutine.Goroutine, error) { + if actual != nil { + switch actual := actual.(type) { + case goroutine.Goroutine: + return actual, nil + case *goroutine.Goroutine: + return *actual, nil + } + } + return goroutine.Goroutine{}, + fmt.Errorf("%s matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n%s", + matchername, format.Object(actual, 1)) +} + +// goids returns a (sorted) list of Goroutine IDs in textual format. +func goids(gs []goroutine.Goroutine) string { + ids := make([]uint64, len(gs)) + for idx, g := range gs { + ids[idx] = g.ID + } + sort.Sort(Uint64Slice(ids)) + var buff strings.Builder + for idx, id := range ids { + if idx > 0 { + buff.WriteString(", ") + } + buff.WriteString(strconv.FormatInt(int64(id), 10)) + } + return buff.String() +} + +// Uint64Slice implements the sort.Interface for a []uint64 to sort in +// increasing order. +type Uint64Slice []uint64 + +func (s Uint64Slice) Len() int { return len(s) } +func (s Uint64Slice) Less(a, b int) bool { return s[a] < s[b] } +func (s Uint64Slice) Swap(a, b int) { s[a], s[b] = s[b], s[a] } diff --git a/gleak/util_test.go b/gleak/util_test.go new file mode 100644 index 000000000..cff6efd08 --- /dev/null +++ b/gleak/util_test.go @@ -0,0 +1,48 @@ +package gleak + +import ( + "github.com/onsi/gomega/gleak/goroutine" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("utilities", func() { + + Context("G(oroutine) descriptions", func() { + + It("returns an error for actual ", func() { + Expect(func() { _, _ = G(nil, "foo") }).NotTo(Panic()) + Expect(G(nil, "foo")).Error().To(MatchError("foo matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : nil")) + }) + + It("returns an error when passing something that's not a goroutine by any means", func() { + Expect(func() { _, _ = G("foobar", "foo") }).NotTo(Panic()) + Expect(G("foobar", "foo")).Error().To(MatchError("foo matcher expects a goroutine.Goroutine or *goroutine.Goroutine. Got:\n : foobar")) + }) + + It("returns a goroutine", func() { + actual := goroutine.Goroutine{ID: 42} + g, err := G(actual, "foo") + Expect(err).NotTo(HaveOccurred()) + Expect(g.ID).To(Equal(uint64(42))) + + g, err = G(&actual, "foo") + Expect(err).NotTo(HaveOccurred()) + Expect(g.ID).To(Equal(uint64(42))) + }) + + }) + + It("returns a list of Goroutine IDs in textual format", func() { + Expect(goids(nil)).To(BeEmpty()) + Expect(goids([]goroutine.Goroutine{ + {ID: 666}, + {ID: 42}, + })).To(Equal("42, 666")) + Expect(goids([]goroutine.Goroutine{ + {ID: 42}, + })).To(Equal("42")) + }) + +})