Skip to content

Commit

Permalink
stack: Parse all functions
Browse files Browse the repository at this point in the history
Adds support to the stack parser
for reading the full list of functions
for a stack trace.

This includes the function that created the stack trace;
it's the bottom of the stack.

We don't maintain the order of the functions
since that's not something we need at this time.
The functions are all placed in a set.
  • Loading branch information
abhinav committed Oct 21, 2023
1 parent 2f2c1d5 commit 06f4c64
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 17 deletions.
75 changes: 64 additions & 11 deletions internal/stack/stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ const _defaultBufferSize = 64 * 1024 // 64 KiB

// Stack represents a single Goroutine's stack.
type Stack struct {
id int
state string
id int
state string // e.g. 'running', 'chan receive'

// The first function on the stack.
firstFunction string

// A set of all functions in the stack,
allFunctions map[string]struct{}

// Full, raw stack trace.
fullStack string
}
Expand All @@ -62,6 +67,13 @@ func (s Stack) FirstFunction() string {
return s.firstFunction
}

// HasFunction reports whether the stack has the given function
// anywhere in it.
func (s Stack) HasFunction(name string) bool {
_, ok := s.allFunctions[name]
return ok
}

func (s Stack) String() string {
return fmt.Sprintf(
"Goroutine %v in state %v, with %v on top of the stack:\n%s",
Expand Down Expand Up @@ -122,8 +134,12 @@ func (p *stackParser) parseStack(line string) (Stack, error) {
firstFunction string
fullStack bytes.Buffer
)
funcs := make(map[string]struct{})
for p.scan.Scan() {
line := p.scan.Text()
if len(line) == 0 {
continue
}

if strings.HasPrefix(line, "goroutine ") {
// If we see the goroutine header,
Expand All @@ -136,19 +152,37 @@ func (p *stackParser) parseStack(line string) (Stack, error) {
fullStack.WriteString(line)
fullStack.WriteByte('\n') // scanner trims the newline

// The first line after the header is the top of the stack.
funcName, err := parseFuncName(line)
if err != nil {
return Stack{}, fmt.Errorf("parse function: %w", err)
}
funcs[funcName] = struct{}{}
if firstFunction == "" {
firstFunction, err = parseFirstFunc(line)
if err != nil {
return Stack{}, fmt.Errorf("extract function: %w", err)
firstFunction = funcName
}

// The function name is usually followed by a line in the form:
//
// <tab>example.com/path/to/package/file.go:123 +0x123
//
// We don't care about the position, so we'll skip it,
// but only if it matches the expected format.
if p.scan.Scan() {
bs := p.scan.Bytes()
if len(bs) > 0 && bs[0] == '\t' {
continue
}

// Put it back if it doesn't match.
p.scan.Unscan()

Check warning on line 177 in internal/stack/stacks.go

View check run for this annotation

Codecov / codecov/patch

internal/stack/stacks.go#L177

Added line #L177 was not covered by tests
}
}

return Stack{
id: id,
state: state,
firstFunction: firstFunction,
allFunctions: funcs,
fullStack: fullStack.String(),
}, nil
}
Expand All @@ -172,12 +206,31 @@ func getStackBuffer(all bool) []byte {
}
}

func parseFirstFunc(line string) (string, error) {
line = strings.TrimSpace(line)
if idx := strings.LastIndex(line, "("); idx > 0 {
return line[:idx], nil
// Parses a single function from the given line.
// The line is in one of these formats:
//
// example.com/path/to/package.funcName(args...)
// example.com/path/to/package.(*typeName).funcName(args...)
// created by example.com/path/to/package.funcName in goroutine [...]
func parseFuncName(line string) (string, error) {
var name string
if after, ok := strings.CutPrefix(line, "created by "); ok {
// The function name is the part after "created by "
// and before " in goroutine [...]".
idx := strings.Index(after, " in goroutine")
if idx >= 0 {
name = after[:idx]
}
} else if idx := strings.LastIndexByte(line, '('); idx >= 0 {
// The function name is the part before the last '('.
name = line[:idx]
}

if name == "" {
return "", fmt.Errorf("no function found: %q", line)
}
return "", fmt.Errorf("no function found: %q", line)

return name, nil
}

// parseGoStackHeader parses a stack header that looks like:
Expand Down
70 changes: 64 additions & 6 deletions internal/stack/stacks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,39 +68,65 @@ func TestAll(t *testing.T) {
sort.Sort(byGoroutineID(got))

assert.Contains(t, got[0].Full(), "testing.(*T).Run")
assert.Contains(t, got[0].allFunctions, "testing.(*T).Run")

assert.Contains(t, got[1].Full(), "TestAll")
assert.Contains(t, got[1].allFunctions, "go.uber.org/goleak/internal/stack.TestAll")

for i := 0; i < 5; i++ {
assert.Contains(t, got[2+i].Full(), "stack.waitForDone")
}
}

func TestCurrent(t *testing.T) {
const pkgPrefix = "go.uber.org/goleak/internal/stack"

got := Current()
assert.NotZero(t, got.ID(), "Should get non-zero goroutine id")
assert.Equal(t, "running", got.State())
assert.Equal(t, "go.uber.org/goleak/internal/stack.getStackBuffer", got.FirstFunction())

wantFrames := []string{
"stack.getStackBuffer",
"stack.getStacks",
"stack.Current",
"stack.Current",
"stack.TestCurrent",
"getStackBuffer",
"getStacks",
"Current",
"Current",
"TestCurrent",
}
all := got.Full()
for _, frame := range wantFrames {
assert.Contains(t, all, frame)
name := pkgPrefix + "." + frame
assert.Contains(t, all, name)
assert.True(t, got.HasFunction(name), "missing in stack: %v\n%s", name, all)
}
assert.Contains(t, got.String(), "in state")
assert.Contains(t, got.String(), "on top of the stack")

assert.True(t, got.HasFunction("testing.(*T).Run"),
"missing in stack: %v\n%s", "testing.(*T).Run", all)

// Ensure that we are not returning the buffer without slicing it
// from getStackBuffer.
if len(got.Full()) > 1024 {
t.Fatalf("Returned stack is too large")
}
}

func TestCurrentCreatedBy(t *testing.T) {
var stack Stack
done := make(chan struct{})
go func() {
defer close(done)
stack = Current()
}()
<-done

// This test function will be at the bottom of the stack
// as the function that created the goroutine.
assert.True(t,
stack.HasFunction("go.uber.org/goleak/internal/stack.TestCurrentCreatedBy"))
}

func TestAllLargeStack(t *testing.T) {
const (
stackDepth = 100
Expand Down Expand Up @@ -134,6 +160,38 @@ func TestAllLargeStack(t *testing.T) {
close(done)
}

func TestParseFuncName(t *testing.T) {
tests := []struct {
name string
give string
want string
}{
{
name: "function",
give: "example.com/foo/bar.baz()",
want: "example.com/foo/bar.baz",
},
{
name: "method",
give: "example.com/foo/bar.(*baz).qux()",
want: "example.com/foo/bar.(*baz).qux",
},
{
name: "created by",
give: "created by example.com/foo/bar.baz in goroutine 123",
want: "example.com/foo/bar.baz",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseFuncName(tt.give)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestParseStackErrors(t *testing.T) {
tests := []struct {
name string
Expand Down

0 comments on commit 06f4c64

Please sign in to comment.