Extracts stack trace formatting logic and adds more context (#434)

Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Crypt Keeper
2022-04-04 19:47:51 +08:00
committed by GitHub
parent abb3559310
commit 3a6cabfb8a
10 changed files with 367 additions and 99 deletions

144
internal/wasmdebug/debug.go Normal file
View File

@@ -0,0 +1,144 @@
// Package wasmdebug contains utilities used to give consistent search keys between stack traces and error messages.
// Note: This is named wasmdebug to avoid conflicts with the normal go module.
// Note: This only imports "api" as importing "wasm" would create a cyclic dependency.
package wasmdebug
import (
"fmt"
"runtime"
"runtime/debug"
"strconv"
"strings"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/internal/buildoptions"
"github.com/tetratelabs/wazero/internal/wasmruntime"
)
// FuncName returns the naming convention of "moduleName.funcName".
//
// * moduleName is the possibly empty name the module was instantiated with.
// * funcName is the name in the Custom Name section, an export name, or what the host defines.
// * funcIdx is the position in the function index namespace, prefixed with imported functions.
//
// Note: "moduleName.[funcIdx]" is used when the funcName is empty, as commonly the case in TinyGo.
func FuncName(moduleName, funcName string, funcIdx uint32) string {
var ret strings.Builder
// Start module.function
ret.WriteString(moduleName)
ret.WriteByte('.')
if funcName == "" {
ret.WriteByte('[')
ret.WriteString(strconv.Itoa(int(funcIdx)))
ret.WriteByte(']')
} else {
ret.WriteString(funcName)
}
return ret.String()
}
// signature returns a formatted signature similar to how it is defined in Go.
//
// * paramTypes should be from wasm.FunctionType
// * resultTypes should be from wasm.FunctionType
func addSignature(funcName string, paramTypes []api.ValueType, resultTypes []api.ValueType) string {
var ret strings.Builder
ret.WriteString(funcName)
// Start params
ret.WriteByte('(')
paramCount := len(paramTypes)
switch paramCount {
case 0:
case 1:
ret.WriteString(api.ValueTypeName(paramTypes[0]))
default:
ret.WriteString(api.ValueTypeName(paramTypes[0]))
for _, vt := range paramTypes[1:] {
ret.WriteByte(',')
ret.WriteString(api.ValueTypeName(vt))
}
}
ret.WriteByte(')')
// Start results
resultCount := len(resultTypes)
switch resultCount {
case 0:
case 1:
ret.WriteByte(' ')
ret.WriteString(api.ValueTypeName(resultTypes[0]))
default: // As this is used for errors, don't panic if there are multiple returns, even if that's invalid!
ret.WriteByte(' ')
ret.WriteByte('(')
ret.WriteString(api.ValueTypeName(resultTypes[0]))
for _, vt := range resultTypes[1:] {
ret.WriteByte(',')
ret.WriteString(api.ValueTypeName(vt))
}
ret.WriteByte(')')
}
return ret.String()
}
// ErrorBuilder helps build consistent errors, particularly adding a WASM stack trace.
//
// AddFrame should be called beginning at the frame that panicked until no more frames exist. Once done, call Format.
type ErrorBuilder interface {
// AddFrame adds the next frame.
//
// * funcName should be from FuncName
// * paramTypes should be from wasm.FunctionType
// * resultTypes should be from wasm.FunctionType
//
// Note: paramTypes and resultTypes are present because signature misunderstanding, mismatch or overflow are common.
AddFrame(funcName string, paramTypes, resultTypes []api.ValueType)
// FromRecovered returns an error with the wasm stack trace appended to it.
FromRecovered(recovered interface{}) error
}
func NewErrorBuilder() ErrorBuilder {
return &stackTrace{}
}
type stackTrace struct {
frames []string
}
func (s *stackTrace) FromRecovered(recovered interface{}) error {
if buildoptions.IsDebugMode {
debug.PrintStack()
}
stack := strings.Join(s.frames, "\n\t")
// If the error was internal, don't mention it was recovered.
if wasmErr, ok := recovered.(*wasmruntime.Error); ok {
return fmt.Errorf("wasm error: %w\nwasm stack trace:\n\t%s", wasmErr, stack)
}
// If we have a runtime.Error, something severe happened which should include the stack trace. This could be
// a nil pointer from wazero or a user-defined function from ModuleBuilder.
if runtimeErr, ok := recovered.(runtime.Error); ok {
// TODO: consider adding debug.Stack(), but last time we attempted, some tests became unstable.
return fmt.Errorf("%w (recovered by wazero)\nwasm stack trace:\n\t%s", runtimeErr, stack)
}
// At this point we expect the error was from a function defined by ModuleBuilder that intentionally called panic.
if runtimeErr, ok := recovered.(error); ok { // Ex. panic(errors.New("whoops"))
return fmt.Errorf("%w (recovered by wazero)\nwasm stack trace:\n\t%s", runtimeErr, stack)
} else { // Ex. panic("whoops")
return fmt.Errorf("%v (recovered by wazero)\nwasm stack trace:\n\t%s", recovered, stack)
}
}
// AddFrame implements ErrorBuilder.Format
func (s *stackTrace) AddFrame(funcName string, paramTypes, resultTypes []api.ValueType) {
// Format as best as we can, considering we don't yet have source and line numbers,
// TODO: include DWARF symbols. See #58
s.frames = append(s.frames, addSignature(funcName, paramTypes, resultTypes))
}

View File

@@ -0,0 +1,145 @@
package wasmdebug
import (
"errors"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/internal/wasmruntime"
)
func TestFuncName(t *testing.T) {
for _, tc := range []struct {
name, moduleName, funcName string
funcIdx uint32
expected string
}{ // Only tests a few edge cases to show what it might end up as.
{name: "empty", expected: ".[0]"},
{name: "empty module", funcName: "y", expected: ".y"},
{name: "empty function", moduleName: "x", funcIdx: 255, expected: "x.[255]"},
{name: "looks like index in function", moduleName: "x", funcName: "[255]", expected: "x.[255]"},
{name: "no special characters", moduleName: "x", funcName: "y", expected: "x.y"},
{name: "dots in module", moduleName: "w.x", funcName: "y", expected: "w.x.y"},
{name: "dots in function", moduleName: "x", funcName: "y.z", expected: "x.y.z"},
{name: "spaces in module", moduleName: "w x", funcName: "y", expected: "w x.y"},
{name: "spaces in function", moduleName: "x", funcName: "y z", expected: "x.y z"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
funcName := FuncName(tc.moduleName, tc.funcName, tc.funcIdx)
require.Equal(t, tc.expected, funcName)
})
}
}
func TestAddSignature(t *testing.T) {
i32, i64, f32, f64 := api.ValueTypeI32, api.ValueTypeI64, api.ValueTypeF32, api.ValueTypeF64
for _, tc := range []struct {
name string
paramTypes, resultTypes []api.ValueType
expected string
}{
{name: "v_v", expected: "x.y()"},
{name: "i32_v", paramTypes: []api.ValueType{i32}, expected: "x.y(i32)"},
{name: "i32f64_v", paramTypes: []api.ValueType{i32, f64}, expected: "x.y(i32,f64)"},
{name: "f32i32f64_v", paramTypes: []api.ValueType{f32, i32, f64}, expected: "x.y(f32,i32,f64)"},
{name: "v_i64", resultTypes: []api.ValueType{i64}, expected: "x.y() i64"},
{name: "v_i64f32", resultTypes: []api.ValueType{i64, f32}, expected: "x.y() (i64,f32)"},
{name: "v_f32i32f64", resultTypes: []api.ValueType{f32, i32, f64}, expected: "x.y() (f32,i32,f64)"},
{name: "i32_i64", paramTypes: []api.ValueType{i32}, resultTypes: []api.ValueType{i64}, expected: "x.y(i32) i64"},
{name: "i64f32_i64f32", paramTypes: []api.ValueType{i64, f32}, resultTypes: []api.ValueType{i64, f32}, expected: "x.y(i64,f32) (i64,f32)"},
{name: "i64f32f64_f32i32f64", paramTypes: []api.ValueType{i64, f32, f64}, resultTypes: []api.ValueType{f32, i32, f64}, expected: "x.y(i64,f32,f64) (f32,i32,f64)"},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
withSignature := addSignature("x.y", tc.paramTypes, tc.resultTypes)
require.Equal(t, tc.expected, withSignature)
})
}
}
func TestErrorBuilder(t *testing.T) {
argErr := errors.New("invalid argument")
rteErr := testRuntimeErr("index out of bounds")
i32 := api.ValueTypeI32
i32i32i32i32 := []api.ValueType{i32, i32, i32, i32}
for _, tc := range []struct {
name string
build func(ErrorBuilder) error
expectedErr string
expectUnwrap error
}{
{
name: "one",
build: func(builder ErrorBuilder) error {
builder.AddFrame("x.y", nil, nil)
return builder.FromRecovered(argErr)
},
expectedErr: `invalid argument (recovered by wazero)
wasm stack trace:
x.y()`,
expectUnwrap: argErr,
},
{
name: "two",
build: func(builder ErrorBuilder) error {
builder.AddFrame("wasi_snapshot_preview1.fd_write", i32i32i32i32, []api.ValueType{i32})
builder.AddFrame("x.y", nil, nil)
return builder.FromRecovered(argErr)
},
expectedErr: `invalid argument (recovered by wazero)
wasm stack trace:
wasi_snapshot_preview1.fd_write(i32,i32,i32,i32) i32
x.y()`,
expectUnwrap: argErr,
},
{
name: "runtime.Error",
build: func(builder ErrorBuilder) error {
builder.AddFrame("wasi_snapshot_preview1.fd_write", i32i32i32i32, []api.ValueType{i32})
builder.AddFrame("x.y", nil, nil)
return builder.FromRecovered(rteErr)
},
expectedErr: `index out of bounds (recovered by wazero)
wasm stack trace:
wasi_snapshot_preview1.fd_write(i32,i32,i32,i32) i32
x.y()`,
expectUnwrap: rteErr,
},
{
name: "wasmruntime.Error",
build: func(builder ErrorBuilder) error {
builder.AddFrame("wasi_snapshot_preview1.fd_write", i32i32i32i32, []api.ValueType{i32})
builder.AddFrame("x.y", nil, nil)
return builder.FromRecovered(wasmruntime.ErrRuntimeCallStackOverflow)
},
expectedErr: `wasm error: callstack overflow
wasm stack trace:
wasi_snapshot_preview1.fd_write(i32,i32,i32,i32) i32
x.y()`,
expectUnwrap: wasmruntime.ErrRuntimeCallStackOverflow,
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
withStackTrace := tc.build(NewErrorBuilder())
require.Equal(t, tc.expectUnwrap, errors.Unwrap(withStackTrace))
require.EqualError(t, withStackTrace, tc.expectedErr)
})
}
}
// compile-time check to ensure testRuntimeErr implements runtime.Error.
var _ runtime.Error = testRuntimeErr("")
type testRuntimeErr string
func (e testRuntimeErr) RuntimeError() {}
func (e testRuntimeErr) Error() string {
return string(e)
}