From 442c4df0856ad21038fe7021ccc3dfbe53a1af4f Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Sat, 21 Aug 2021 12:52:21 +0300 Subject: [PATCH] Add errors package with optional stacktrace capturing The goal of this package is to enable to capture stack traces when the error are being raised, which speeds up the development process, if enabled at compile time. To do so, this set of changes introduces an internal/errors package to handle errors with four functions: .New, .Errorf, .Is and .Unwrap. Albeit support for reflect has been added recently to tinygo, it presently not depend on reflect in any way. If the code is compiled with the debug build tag, the stack trace will be captured when an error is created or wrapped (both with .New). Without that build tag, the errors packages falls back to simply comparing values and performs no wrapping at all. It also come with a new testutil/assert package which replaces the require package when it comes to checking or comparing errors and printing the stack traces if needed. Finally, the test target of the Makefile uses the debug build tag by default. A testnodebug target is also provided for convenience and to make sure no tests breaks due to not having used the internal/errors or testutil/assert package. --- internal/errors/error.go | 69 ++++++++++ internal/errors/errors.go | 41 ++++++ internal/errors/errors_debug.go | 108 ++++++++++++++++ internal/errors/errors_debug_test.go | 186 +++++++++++++++++++++++++++ internal/errors/stackframe.go | 110 ++++++++++++++++ 5 files changed, 514 insertions(+) create mode 100644 internal/errors/error.go create mode 100644 internal/errors/errors.go create mode 100644 internal/errors/errors_debug.go create mode 100644 internal/errors/errors_debug_test.go create mode 100644 internal/errors/stackframe.go diff --git a/internal/errors/error.go b/internal/errors/error.go new file mode 100644 index 000000000..d64535b06 --- /dev/null +++ b/internal/errors/error.go @@ -0,0 +1,69 @@ +package errors + +import ( + "bytes" + "reflect" +) + +// Error wraps any error with a stacktrace, speeding up the development process. +// Such errors are only returned when the error package is compiled with the "debug" build tag. +type Error struct { + Err error + stack []uintptr + frames []StackFrame +} + +// Error returns the underlying error's message. +func (err *Error) Error() string { + return err.Err.Error() +} + +// Return the underlying error. +func (err *Error) Unwrap() error { + return err.Err +} + +// Is returns true if err equals to the target or the error wrapped by the target. +func (err *Error) Is(target error) bool { + if err == target { + return true + } + if e, ok := target.(*Error); ok { + return err.Err == e.Err + } + return false +} + +// Stack returns the callstack formatted the same way that go does +// in runtime/debug.Stack() +func (err *Error) Stack() []byte { + buf := bytes.Buffer{} + for _, frame := range err.StackFrames() { + buf.WriteString(frame.String()) + } + return buf.Bytes() +} + +// StackFrames returns an array of frames containing information about the +// stack. +func (err *Error) StackFrames() []StackFrame { + if err.frames == nil { + err.frames = make([]StackFrame, len(err.stack)) + + for i, pc := range err.stack { + err.frames[i] = NewStackFrame(pc) + } + } + return err.frames +} + +// ErrorStack returns a string that contains both the +// error message and the callstack. +func (err *Error) ErrorStack() string { + return err.TypeName() + " " + err.Error() + "\n" + string(err.Stack()) +} + +// TypeName returns the type this error. e.g. *errors.stringError. +func (err *Error) TypeName() string { + return reflect.TypeOf(err.Err).String() +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 000000000..1710433c5 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,41 @@ +// +build !debug + +// Package errors provides a simple API to create and compare errors. A debug version of this package +// exists, but captures stacktraces when error are created or wrapped. It is accessible through the +// the "debug" build tag. +package errors + +import ( + baseErrors "errors" + + "github.com/genjidb/genji/internal/stringutil" +) + +// New takes a string and returns a standard error. +func New(s string) error { + return baseErrors.New(s) +} + +// Errorf creates an error out of a string. If %w is used to format an error, it will +// only wrap it by concatenation, the wrapped error won't be accessible directly and +// thus cannot be accessed through the Is or As functions from the standard error package. +func Errorf(format string, a ...interface{}) error { + return stringutil.Errorf(format, a...) +} + +// Is performs a simple value comparison between err and original (==). +func Is(err, original error) bool { + return err == original +} + +// Unwrap does nothing and just returns err. +// This function only acts differently when the debug version of this function is used. +func Unwrap(err error) error { + return err +} + +// Wrap acts as the identity function, unless compiled with the debug tag. +// See the debug version of this package for more. +func Wrap(err error) error { + return err +} diff --git a/internal/errors/errors_debug.go b/internal/errors/errors_debug.go new file mode 100644 index 000000000..be0c513e2 --- /dev/null +++ b/internal/errors/errors_debug.go @@ -0,0 +1,108 @@ +// +build debug + +// Package errors provides a simple API to create and compare errors. +// It captures the stacktrace when an error is created or wrapped, which can be then be inspected for debugging purposes. +// This package, compiled with the "debug" build tag is only meant to ease development and should not be used otherwise. +package errors + +import ( + baseErrors "errors" + "runtime" + + "github.com/genjidb/genji/internal/stringutil" +) + +// New takes a string and returns a wrapped error that allows to inspect the stracktrace +// captured when this function was called. +func New(s string) error { + err := _new(s) + if len(err.stack) > 1 { + // Truncate the call to _new + err.stack = err.stack[1:] + } + return err +} + +// Errorf creates an error that includes the stracktrace, out of a string. If %w is used to format an error, it will +// only wrap it by concatenation, the wrapped error won't be accessible directly and +// thus cannot be accessed through the Is or As functions from the standard error package. +func Errorf(format string, a ...interface{}) error { + return errorf(format, a...) +} + +// Is performs a value comparison between err and the target, unwrapping them if necessary. +func Is(err, target error) bool { + if err == target { + return true + } + if e, ok := err.(*Error); ok { + if t, ok := target.(*Error); ok { + return e.Err == t.Err + } else { + return e.Err == target + } + } + if target, ok := target.(*Error); ok { + return err == target.Err + } + return false +} + +// Unwrap returns the underlying error, or the error itself if err is not an *errors.Error. +func Unwrap(err error) error { + if err == nil { + return nil + } + if e, ok := err.(*Error); ok { + return e.Err + } + return err +} + +// Wrap turns any error into an *errors.Error that also embeds the stacktrace. +// If the error is already wrapped, it will refresh its captured stacktrace, +// which is useful when the original context in which it was created has +// no meaning for the user. +func Wrap(e error) error { + if e == nil { + return nil + } + return wrap(e, 1) +} + +// The maximum number of stackframes on any error. +var MaxStackDepth = 32 + +func _new(s string) *Error { + err := baseErrors.New(s) + return wrap(err, 1) +} + +// wrap makes an Error from the given value. If that value is already an +// error then it will be used directly, if not, it will be passed to +// stringutil.Errorf("%v"). The skip parameter indicates how far up the stack +// to start the stacktrace. 0 is from the current call, 1 from its caller, etc. +func wrap(e interface{}, skip int) *Error { + if e == nil { + return nil + } + var err error + switch e := e.(type) { + case *Error: + err = e + case error: + err = e + default: + err = stringutil.Errorf("%v", e) + } + stack := make([]uintptr, MaxStackDepth) + length := runtime.Callers(2+skip, stack[:]) + return &Error{ + Err: err, + stack: stack[:length], + } +} + +func errorf(format string, a ...interface{}) *Error { + return wrap(stringutil.Errorf(format, a...), 1) +} diff --git a/internal/errors/errors_debug_test.go b/internal/errors/errors_debug_test.go new file mode 100644 index 000000000..5ff494e6f --- /dev/null +++ b/internal/errors/errors_debug_test.go @@ -0,0 +1,186 @@ +// +build debug + +package errors + +import ( + "bytes" + "io" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/genjidb/genji/internal/stringutil" + "github.com/stretchr/testify/require" +) + +var currentFilename string + +func init() { + _, path, _, _ := runtime.Caller(0) + currentFilename = filepath.Base(path) +} + +func TestStackFormat(t *testing.T) { + defer func() { + err := recover() + if err != 'a' { + t.Fatal(err) + } + e, expected := errorf("hi"), callers() + bs := [][]uintptr{e.stack, expected} + if err := compareStacks(bs[0], bs[1]); err != nil { + t.Errorf("Stack didn't match") + t.Errorf(err.Error()) + } + stack := string(e.Stack()) + if !strings.Contains(stack, "a: b(5)") { + t.Errorf("Stack trace does not contain source line: 'a: b(5)'") + t.Errorf(stack) + } + if !strings.Contains(stack, currentFilename+":") { + t.Errorf("Stack trace does not contain file name: '%s:'", currentFilename) + t.Errorf(stack) + } + }() + _ = a() +} + +func TestSkipWorks(t *testing.T) { + defer func() { + err := recover() + if err != 'a' { + t.Fatal(err) + } + bs := [][]uintptr{wrap("hi", 2).stack, callersSkip(2)} + if err := compareStacks(bs[0], bs[1]); err != nil { + t.Errorf("Stack didn't match") + t.Errorf(err.Error()) + } + }() + _ = a() +} + +func TestNew(t *testing.T) { + err := _new("foo") + if err.Error() != "foo" { + t.Errorf("Wrong message") + } + bs := [][]uintptr{_new("foo").stack, callers()} + if err := compareStacks(bs[0], bs[1]); err != nil { + t.Errorf("Stack didn't match") + t.Errorf(err.Error()) + } + if err.ErrorStack() != err.TypeName()+" "+err.Error()+"\n"+string(err.Stack()) { + t.Errorf("ErrorStack is in the wrong format") + } +} + +func TestIs(t *testing.T) { + if Is(nil, io.EOF) { + t.Errorf("nil is an error") + } + if !Is(io.EOF, io.EOF) { + t.Errorf("io.EOF is not io.EOF") + } + if !Is(Wrap(io.EOF), io.EOF) { + t.Errorf("_new(io.EOF) is not io.EOF") + } + if !Is(io.EOF, Wrap(io.EOF)) { + t.Errorf("io.EOF is not New(io.EOF)") + } + if !Is(Wrap(io.EOF), Wrap(io.EOF)) { + t.Errorf("New(io.EOF) is not New(io.EOF)") + } + if Is(io.EOF, stringutil.Errorf("io.EOF")) { + t.Errorf("io.EOF is stringutil.Errorf") + } +} + +func TestRequireIsError(t *testing.T) { + require.ErrorIs(t, Wrap(io.EOF), io.EOF) +} + +func TestWrapError(t *testing.T) { + e := func() error { + return wrap("hi", 1) + }() + if e.Error() != "hi" { + t.Errorf("Constructor with a string failed") + } + if wrap(stringutil.Errorf("yo"), 0).Error() != "yo" { + t.Errorf("Constructor with an error failed") + } + if wrap(e, 0) != e { + t.Errorf("Constructor with an Error failed") + } + if wrap(nil, 0) != nil { + t.Errorf("Constructor with nil failed") + } +} + +func a() error { + b(5) + return nil +} + +func b(i int) { + c() +} + +func c() { + panic('a') +} + +// compareStacks will compare a stack created using the errors package (actual) +// with a reference stack created with the callers function (expected). The +// first entry is not compared since the actual and expected stacks cannot +// be created at the exact same program counter position so the first entry +// will always differ somewhat. Returns nil if the stacks are equal enough and +// an error containing a detailed error message otherwise. +func compareStacks(actual, expected []uintptr) error { + if len(actual) != len(expected) { + return stackCompareError("Stacks does not have equal length", actual, expected) + } + for i, pc := range actual { + if i != 0 && pc != expected[i] { + return stackCompareError(stringutil.Sprintf("Stacks does not match entry %d (and maybe others)", i), actual, expected) + } + } + return nil +} + +func stackCompareError(msg string, actual, expected []uintptr) error { + return stringutil.Errorf("%s\nActual stack trace:\n%s\nExpected stack trace:\n%s", msg, readableStackTrace(actual), readableStackTrace(expected)) +} + +func callers() []uintptr { + return callersSkip(1) +} + +func callersSkip(skip int) []uintptr { + callers := make([]uintptr, MaxStackDepth) + length := runtime.Callers(skip+2, callers[:]) + return callers[:length] +} + +func readableStackTrace(callers []uintptr) string { + var result bytes.Buffer + frames := callersToFrames(callers) + for _, frame := range frames { + result.WriteString(stringutil.Sprintf("%s:%d (%#x)\n\t%s\n", frame.File, frame.Line, frame.PC, frame.Function)) + } + return result.String() +} + +func callersToFrames(callers []uintptr) []runtime.Frame { + frames := make([]runtime.Frame, 0, len(callers)) + framesPtr := runtime.CallersFrames(callers) + for { + frame, more := framesPtr.Next() + frames = append(frames, frame) + if !more { + return frames + } + } +} diff --git a/internal/errors/stackframe.go b/internal/errors/stackframe.go new file mode 100644 index 000000000..29b67d8e2 --- /dev/null +++ b/internal/errors/stackframe.go @@ -0,0 +1,110 @@ +package errors + +import ( + "bufio" + "bytes" + "os" + "runtime" + "strings" + + "github.com/genjidb/genji/internal/stringutil" +) + +// A StackFrame contains all necessary information about to generate a line +// in a callstack. +type StackFrame struct { + // The path to the file containing this ProgramCounter + File string + // The LineNumber in that file + LineNumber int + // The Name of the function that contains this ProgramCounter + Name string + // The Package that contains this function + Package string + // The underlying ProgramCounter + ProgramCounter uintptr +} + +// NewStackFrame popoulates a stack frame object from the program counter. +func NewStackFrame(pc uintptr) (frame StackFrame) { + frame = StackFrame{ProgramCounter: pc} + if frame.Func() == nil { + return + } + frame.Package, frame.Name = packageAndName(frame.Func()) + + // pc -1 because the program counters we use are usually return addresses, + // and we want to show the line that corresponds to the function call + frame.File, frame.LineNumber = frame.Func().FileLine(pc - 1) + return + +} + +// Func returns the function that contained this frame. +func (frame *StackFrame) Func() *runtime.Func { + if frame.ProgramCounter == 0 { + return nil + } + return runtime.FuncForPC(frame.ProgramCounter) +} + +// String returns the stackframe formatted in the same way as go does +// in runtime/debug.Stack() +func (frame *StackFrame) String() string { + str := stringutil.Sprintf("%s:%d (0x%x)\n", frame.File, frame.LineNumber, frame.ProgramCounter) + source, err := frame.SourceLine() + if err != nil { + return str + } + return str + stringutil.Sprintf("\t%s.%s: %s\n", frame.Package, frame.Name, source) +} + +// SourceLine gets the line of code (from File and Line) of the original source if possible. +func (frame *StackFrame) SourceLine() (string, error) { + if frame.LineNumber <= 0 { + return "???", nil + } + + file, err := os.Open(frame.File) + if err != nil { + return "", Wrap(err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + currentLine := 1 + for scanner.Scan() { + if currentLine == frame.LineNumber { + return string(bytes.Trim(scanner.Bytes(), " \t")), nil + } + currentLine++ + } + if err := scanner.Err(); err != nil { + return "", Wrap(err) + } + return "???", nil +} + +func packageAndName(fn *runtime.Func) (string, string) { + name := fn.Name() + pkg := "" + + // The name includes the path name to the package, which is unnecessary + // since the file name is already included. Plus, it has center dots. + // That is, we see + // runtime/debug.*T·ptrmethod + // and want + // *T.ptrmethod + // Since the package path might contains dots (e.g. code.google.com/...), + // we first remove the path prefix if there is one. + if lastslash := strings.LastIndex(name, "/"); lastslash >= 0 { + pkg += name[:lastslash] + "/" + name = name[lastslash+1:] + } + if period := strings.Index(name, "."); period >= 0 { + pkg += name[:period] + name = name[period+1:] + } + name = strings.Replace(name, "·", ".", -1) + return pkg, name +}