From fab78272041a265c0c33ecf0ea7df3389d4cf582 Mon Sep 17 00:00:00 2001 From: Derek McGowan Date: Thu, 6 Jun 2024 16:04:07 -0700 Subject: [PATCH] Add stack package for managing error stack traces Signed-off-by: Derek McGowan --- stack/stack.go | 300 ++++++++++++++++++++++++++++++++++++++++++++ stack/stack_test.go | 98 +++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 stack/stack.go create mode 100644 stack/stack_test.go diff --git a/stack/stack.go b/stack/stack.go new file mode 100644 index 0000000..c5d07c8 --- /dev/null +++ b/stack/stack.go @@ -0,0 +1,300 @@ +/* + Copyright The containerd 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. +*/ + +package stack + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "runtime" + "strings" + "sync/atomic" + "unsafe" + + "github.com/containerd/typeurl/v2" + + "github.com/containerd/errdefs/internal/types" +) + +func init() { + typeurl.Register((*stack)(nil), "github.com/containerd/errdefs", "stack+json") +} + +// TODO: Add stack context map? + +var ( + // Version is version of running process + Version string = "dev" + + // Revision is the specific revision of the running process + Revision string = "dirty" +) + +type stack struct { + decoded *Trace + + callers []uintptr + helpers []uintptr +} + +// Trace is a stack trace along with process information about the source +type Trace struct { + Version string `json:"version,omitempty"` + Revision string `json:"revision,omitempty"` + Cmdline []string `json:"cmdline,omitempty"` + Frames []Frame `json:"frames,omitempty"` + Pid int32 `json:"pid,omitempty"` +} + +// Frame is a single frame of the trace representing a line of code +type Frame struct { + Name string `json:"Name,omitempty"` + File string `json:"File,omitempty"` + Line int32 `json:"Line,omitempty"` +} + +func (f Frame) Format(s fmt.State, verb rune) { + switch verb { + case 'v': + switch { + case s.Flag('+'): + fmt.Fprintf(s, "%s\n\t%s:%d\n", f.Name, f.File, f.Line) + default: + fmt.Fprint(s, f.Name) + } + case 's': + fmt.Fprint(s, path.Base(f.Name)) + case 'q': + fmt.Fprintf(s, "%q", path.Base(f.Name)) + } +} + +// callers returns the current stack, skipping over the number of frames mentioned +// Frames with skip=0: +// +// frame[0] runtime.Callers +// frame[1] github.com/containerd/errdefs/stack.callers +// frame[2] (Use skip=2 to have this be first frame) +func callers(skip int) *stack { + const depth = 32 + var pcs [depth]uintptr + n := runtime.Callers(skip, pcs[:]) + return &stack{ + callers: pcs[0:n], + } +} + +func (s *stack) getDecoded() *Trace { + if s.decoded == nil { + var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded)) + + var helpers map[string]struct{} + if len(s.helpers) > 0 { + helpers = make(map[string]struct{}) + frames := runtime.CallersFrames(s.helpers) + for { + frame, more := frames.Next() + helpers[frame.Function] = struct{}{} + if !more { + break + } + } + } + + f := make([]Frame, 0, len(s.callers)) + if len(s.callers) > 0 { + frames := runtime.CallersFrames(s.callers) + for { + frame, more := frames.Next() + if _, ok := helpers[frame.Function]; !ok { + f = append(f, Frame{ + Name: frame.Function, + File: frame.File, + Line: int32(frame.Line), + }) + } + if !more { + break + } + } + } + + t := Trace{ + Version: Version, + Revision: Revision, + Cmdline: os.Args, + Frames: f, + Pid: int32(os.Getpid()), + } + + atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t)) + } + + return s.decoded +} + +func (s *stack) Error() string { + return fmt.Sprintf("%+v", s.getDecoded()) +} + +func (s *stack) MarshalJSON() ([]byte, error) { + return json.Marshal(s.getDecoded()) +} + +func (s *stack) UnmarshalJSON(b []byte) error { + var unsafeDecoded = (*unsafe.Pointer)(unsafe.Pointer(&s.decoded)) + var t Trace + + if err := json.Unmarshal(b, &t); err != nil { + return err + } + + atomic.StorePointer(unsafeDecoded, unsafe.Pointer(&t)) + + return nil +} + +func (s *stack) Format(st fmt.State, verb rune) { + switch verb { + case 'v': + if st.Flag('+') { + t := s.getDecoded() + fmt.Fprintf(st, "%d %s %s\n", t.Pid, t.Version, strings.Join(t.Cmdline, " ")) + for _, f := range t.Frames { + f.Format(st, verb) + } + fmt.Fprintln(st) + return + } + } +} + +func (s *stack) StackTrace() Trace { + return *s.getDecoded() +} + +func (s *stack) CollapseError() {} + +// ErrStack returns a new error for the callers stack, +// this can be wrapped or joined into an existing error. +// NOTE: When joined with errors.Join, the stack +// will show up in the error string output. +// Use with `stack.Join` to force addition of the +// error stack. +func ErrStack() error { + return callers(3) +} + +// Join adds a stack if there is no stack included to the errors +// and returns a joined error with the stack hidden from the error +// output. The stack error shows up when Unwrapped or formatted +// with `%+v`. +func Join(errs ...error) error { + return joinErrors(nil, errs) +} + +// WithStack will check if the error already has a stack otherwise +// return a new error with the error joined with a stack error +// Any helpers will be skipped. +func WithStack(ctx context.Context, errs ...error) error { + return joinErrors(ctx, errs) +} + +func joinErrors(ctx context.Context, errs []error) error { + var filtered []error + var collapsible []error + var hasStack bool + for _, err := range errs { + if err != nil { + if !hasStack && hasLocalStackTrace(err) { + hasStack = true + } + if _, ok := err.(types.CollapsibleError); ok { + collapsible = append(collapsible, err) + } else { + filtered = append(filtered, err) + } + + } + } + if len(filtered) == 0 { + return nil + } + if !hasStack { + s := callers(4) + if ctx != nil { + if helpers, ok := ctx.Value(helperKey{}).([]uintptr); ok { + s.helpers = helpers + } + } + collapsible = append(collapsible, s) + } + var err error + if len(filtered) > 1 { + err = errors.Join(filtered...) + } else { + err = filtered[0] + } + if len(collapsible) == 0 { + return err + } + + return types.CollapsedError(err, collapsible...) +} + +func hasLocalStackTrace(err error) bool { + switch e := err.(type) { + case *stack: + return true + case interface{ Unwrap() error }: + if hasLocalStackTrace(e.Unwrap()) { + return true + } + case interface{ Unwrap() []error }: + for _, ue := range e.Unwrap() { + if hasLocalStackTrace(ue) { + return true + } + } + } + + // TODO: Consider if pkg/errors compatibility is needed + // NOTE: This was implemented before the standard error package + // so it may unwrap and have this interface. + //if _, ok := err.(interface{ StackTrace() pkgerrors.StackTrace }); ok { + // return true + //} + + return false +} + +type helperKey struct{} + +// WithHelper marks the context as from a helper function +// This will add an additional skip to the error stack trace +func WithHelper(ctx context.Context) context.Context { + helpers, _ := ctx.Value(helperKey{}).([]uintptr) + var pcs [1]uintptr + n := runtime.Callers(2, pcs[:]) + if n == 1 { + ctx = context.WithValue(ctx, helperKey{}, append(helpers, pcs[0])) + } + return ctx +} diff --git a/stack/stack_test.go b/stack/stack_test.go new file mode 100644 index 0000000..00cb81d --- /dev/null +++ b/stack/stack_test.go @@ -0,0 +1,98 @@ +/* + Copyright The containerd 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. +*/ + +package stack + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" +) + +func TestStack(t *testing.T) { + s := callers(2) + if len(s.callers) == 0 { + t.Fatalf("expected callers, got:\n%v", s) + } + tr := s.getDecoded() + if len(tr.Frames) != len(s.callers) { + t.Fatalf("expected 1 frame, got %d", len(tr.Frames)) + } + if name := tr.Frames[0].Name; !strings.HasSuffix(name, "."+t.Name()) { + t.Fatalf("unexpected frame: %s\n%v", name, s) + } +} + +func TestCollapsed(t *testing.T) { + checkError := func(err error, expected string) { + t.Helper() + if err.Error() != expected { + t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected) + } + + if printed := fmt.Sprintf("%v", err); printed != expected { + t.Fatalf("unexpected error string %q, expected %q", printed, expected) + } + + if printed := fmt.Sprintf("%+v", err); !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) { + t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name()) + } + } + expected := "some error" + checkError(Join(errors.New(expected)), expected) + checkError(Join(errors.New(expected), ErrStack()), expected) + checkError(WithStack(context.Background(), errors.New(expected)), expected) +} + +func TestHelpers(t *testing.T) { + checkError := func(err error, expected string, withHelper bool) { + t.Helper() + if err.Error() != expected { + t.Fatalf("unexpected error string %q, expected %q", err.Error(), expected) + } + + if printed := fmt.Sprintf("%v", err); printed != expected { + t.Fatalf("unexpected error string %q, expected %q", printed, expected) + } + + printed := fmt.Sprintf("%+v", err) + if !strings.HasPrefix(printed, expected) || !strings.Contains(printed, t.Name()) { + t.Fatalf("unexpected error string %q, expected %q with stack containing %q", printed, expected, t.Name()) + } + if withHelper { + if !strings.Contains(printed, "testHelper") { + t.Fatalf("unexpected error string, expected stack containing testHelper:\n%s", printed) + } + } else if strings.Contains(printed, "testHelper") { + t.Fatalf("unexpected error string, expected stack with no containing testHelper:\n%s", printed) + } + } + expected := "some error" + checkError(Join(errors.New(expected)), expected, false) + checkError(testHelper(expected, false), expected, true) + checkError(testHelper(expected, true), expected, false) +} + +func testHelper(msg string, withHelper bool) error { + if withHelper { + return WithStack(WithHelper(context.Background()), errors.New(msg)) + } else { + return WithStack(context.Background(), errors.New(msg)) + } + +}