Extracts stack trace formatting logic and adds more context (#434)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
144
internal/wasmdebug/debug.go
Normal file
144
internal/wasmdebug/debug.go
Normal 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))
|
||||
}
|
||||
145
internal/wasmdebug/debug_test.go
Normal file
145
internal/wasmdebug/debug_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user