Repository: tetratelabs/wazero
Pull request #434 (2022-04-03 03:28:57)

Extracts stack trace formatting logic and adds more context
This changes panics to be more consistent between engines and adds
signature information as it is commonly a problem.

Ex.
```
integer divide by zero (recovered by wazero)
wasm stack trace:
	wasi_snapshot_preview1.fd_write(i32,i32,i32,i32) i32
	x.y()
```

Note: This took out the rust-like index numbers on the stack trace and
also calls it stack trace not backtrace as that's more usual in Go.

codefromthecrypt: TestModuleEngine_Call_HostFn flake shows another thing we needed to fix before, which is that the go stacktrace is lost on nil pointer. I'll try to fix that now.
codefromthecrypt: by reclassifying the errors, we now get a stack trace when something is nil, there's still an issue in jit engine as sometimes this line is nil. Now, we get an error about it including the stack trace!

```
		// Call into the JIT code.
		jitcall(frame.returnAddress, uintptr(unsafe.Pointer(ce)))
```


```
--- FAIL: TestModuleEngine_Call_HostFn (0.00s)
    enginetest.go:234: 
        	Error Trace:	enginetest.go:234
        	            				engine_test.go:134
        	Error:      	Received unexpected error:
        	            	runtime error: invalid memory address or nil pointer dereference (recovered by wazero)
        	            	wasm stack trace:
        	            		TestModuleEngine_Call_HostFn.call__get_uint32(i32) i32
        	            		TestModuleEngine_Call_HostFn_importing.call_import__call__get_uint32(i32) i32
        	            	goroutine 32 [running]:
        	            	runtime/debug.Stack()
        	            		/usr/local/Cellar/go/1.17.8/libexec/src/runtime/debug/stack.go:24 +0x65
        	            	github.com/tetratelabs/wazero/internal/wasmdebug.(*stackTrace).FromRecovered(0xc0000f1930, {0x12bbb60, 0x152d830})
        	            		/Users/adrian/oss/wazero/internal/wasmdebug/debug.go:135 +0xc6
        	            	github.com/tetratelabs/wazero/internal/wasm/jit.(*moduleEngine).Call.func1()
        	            		/Users/adrian/oss/wazero/internal/wasm/jit/engine.go:502 +0x24f
        	            	panic({0x12bbb60, 0x152d830})
        	            		/usr/local/Cellar/go/1.17.8/libexec/src/runtime/panic.go:1038 +0x215
        	            	github.com/tetratelabs/wazero/internal/wasm/jit.(*callEngine).execWasmFunction(0xc0000cc4d0, 0xc000099380, 0xc00017d520)
        	            		/Users/adrian/oss/wazero/internal/wasm/jit/engine.go:710 +0x166
        	            	github.com/tetratelabs/wazero/internal/wasm/jit.(*moduleEngine).Call(0xc000099300, 0xc0000f1dc8, 0xc00011fb20, {0xc0000b50f0, 0x1, 0x3b0cb})
        	            		/Users/adrian/oss/wazero/internal/wasm/jit/engine.go:511 +0x3c9
        	            	github.com/tetratelabs/wazero/internal/testing/enginetest.RunTestModuleEngine_Call_HostFn(0xc00017d520, {0x1370ef8, 0x15ac040})
        	            		/Users/adrian/oss/wazero/internal/testing/enginetest/enginetest.go:233 +0x5db
        	            	github.com/tetratelabs/wazero/internal/wasm/jit.TestModuleEngine_Call_HostFn(0xc00017cea0)
        	            		/Users/adrian/oss/wazero/internal/wasm/jit/engine_test.go:134 +0x27
        	            	testing.tRunner(0xc00017d520, 0x131ebb0)
        	            		/usr/local/Cellar/go/1.17.8/libexec/src/testing/testing.go:1259 +0x102
        	            	created by testing.(*T).Run
        	            		/usr/local/Cellar/go/1.17.8/libexec/src/testing/testing.go:1306 +0x35a
        	Test:       	TestModuleEngine_Call_HostFn
FAIL
```
codefromthecrypt: hmm I wonder if calling debug.Stack() is actually causing a problem across goroutines..
codefromthecrypt: I'll roll back the stack trace thing as we can do that later after getting to the bottom of why there are more flakes
codefromthecrypt: ok looks like a clean run now. `debug.Stack()` either directly or indirectly causes issues, so we'll skip it until understood.
codefromthecrypt: hmm I wonder if these are flakey due to the following statement:

```
// Error is returned by a wasm.Engine during the execution of Wasm functions, and they indicate that the Wasm runtime
// state is unrecoverable.
```

If so, a wasm Error should close the module and the engine, huh? I'll move the engine initialization to inside the test in case ^^ is the culprit.
codefromthecrypt: opened #435 for the JIT panics, there is one flakey interpreter test here and I'll try to understand why:

```
--- FAIL: TestInterpreter_ModuleEngine_Call_HostFn_Errors (0.01s)
[10](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:10)
    --- FAIL: TestInterpreter_ModuleEngine_Call_HostFn_Errors/host_function_panics_with_runtime.Error (0.00s)
[11](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:11)
        enginetest.go:337: 
[12](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:12)
            	Error Trace:	enginetest.go:337
[13](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:13)
            	Error:      	An error is expected but got nil.
[14](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:14)
            	Test:       	TestInterpreter_ModuleEngine_Call_HostFn_Errors/host_function_panics_with_runtime.Error
[15](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:15)
    --- FAIL: TestInterpreter_ModuleEngine_Call_HostFn_Errors/wasm_calls_imported_wasm_that_calls_host_function_panics_with_runtime.Error (0.00s)
[16](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:16)
        enginetest.go:337: 
[17](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:17)
            	Error Trace:	enginetest.go:337
[18](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:18)
            	Error:      	An error is expected but got nil.
[19](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:19)
            	Test:       	TestInterpreter_ModuleEngine_Call_HostFn_Errors/wasm_calls_imported_wasm_that_calls_host_function_panics_with_runtime.Error
[20](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:20)
    --- FAIL: TestInterpreter_ModuleEngine_Call_HostFn_Errors/wasm_calls_imported_wasm_calls_host_function_panics_with_runtime.Error (0.00s)
[21](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:21)
        enginetest.go:337: 
[22](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:22)
            	Error Trace:	enginetest.go:337
[23](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:23)
            	Error:      	An error is expected but got nil.
[24](https://github.com/tetratelabs/wazero/runs/5805662350?check_suite_focus=true#step:8:24)
            	Test:       	TestInterpreter_ModuleEngine_Call_HostFn_Errors/wasm_calls_imported_wasm_calls_host_function_panics_with_runtime.Error
```
codefromthecrypt: I have an idea.. we should have a test after anything errs to make sure the module is still usable. Hopefully, this can catch some of the problems easier.
mathetake: ```
 go test ./internal/wasm/jit/... -count=10    
--- FAIL: TestJIT_ModuleEngine_Call_HostFn (0.00s)
    enginetest.go:237: 
                Error Trace:    enginetest.go:237
                                                        engine_test.go:136
                Error:          Received unexpected error:
                                runtime error: invalid memory address or nil pointer dereference (recovered by wazero)
                                wasm stack trace:
                                        TestJIT_ModuleEngine_Call_HostFn.call__get_uint32(i32) i32
                                        TestJIT_ModuleEngine_Call_HostFn_importing.call_import__call__get_uint32(i32) i32
                Test:           TestJIT_ModuleEngine_Call_HostFn
--- FAIL: TestJIT_ModuleEngine_Call_HostFn (0.00s)
    enginetest.go:237: 
                Error Trace:    enginetest.go:237
                                                        engine_test.go:136
                Error:          Received unexpected error:
                                runtime error: invalid memory address or nil pointer dereference (recovered by wazero)
                                wasm stack trace:
                                        TestJIT_ModuleEngine_Call_HostFn.call__get_uint32(i32) i32
                                        TestJIT_ModuleEngine_Call_HostFn_importing.call_import__call__get_uint32(i32) i32
                Test:           TestJIT_ModuleEngine_Call_HostFn
FAIL
FAIL    github.com/tetratelabs/wazero/internal/wasm/jit 8.114s
```


seems like the interpreter error is the same cause for JIT
mathetake: ok found the root cause
codefromthecrypt: switching approaches. adding tests that pass in a separate PR then it is easier to see what doesn't work https://github.com/tetratelabs/wazero/pull/436
codefromthecrypt: ok this should be ready. I won't try adding the go stack on panic to this PR as I don't want to push my luck :D

Patch:
diff --git a/internal/wasm/host.go b/internal/wasm/host.go
index 8427ec5b..00bbd592 100644
--- a/internal/wasm/host.go
+++ b/internal/wasm/host.go
@@ -4,6 +4,8 @@ import (
 	"fmt"
 	"reflect"
 	"sort"
+
+	"github.com/tetratelabs/wazero/internal/wasmdebug"
 )
 
 // NewHostModule is defined internally for use in WASI tests and to keep the code size in the root directory small.
@@ -88,17 +90,19 @@ func (m *Module) validateHostFunctions() error {
 	return nil
 }
 
-func (m *Module) buildHostFunctionInstances() (functions []*FunctionInstance) {
+func (m *Module) buildHostFunctions(moduleName string) (functions []*FunctionInstance) {
+	// ModuleBuilder has no imports, which means the FunctionSection index is the same as the position in the function
+	// index namespace. Also, it ensures every function has a name. That's why there is less error checking here.
 	var functionNames = m.NameSection.FunctionNames
 	for idx, typeIndex := range m.FunctionSection {
 		fn := m.HostFunctionSection[idx]
 		f := &FunctionInstance{
-			DebugName: functionNames[idx].Name,
-			Kind:      kind(fn.Type()),
-			Type:      m.TypeSection[typeIndex],
-			GoFunc:    fn,
-			Index:     Index(idx),
+			Kind:   kind(fn.Type()),
+			Type:   m.TypeSection[typeIndex],
+			GoFunc: fn,
+			Index:  Index(idx),
 		}
+		f.DebugName = wasmdebug.FuncName(moduleName, functionNames[f.Index].Name, f.Index)
 		functions = append(functions, f)
 	}
 	return
diff --git a/internal/wasm/interpreter/interpreter.go b/internal/wasm/interpreter/interpreter.go
index 8b25c7bb..771b19b1 100644
--- a/internal/wasm/interpreter/interpreter.go
+++ b/internal/wasm/interpreter/interpreter.go
@@ -6,13 +6,13 @@ import (
 	"math"
 	"math/bits"
 	"reflect"
-	"runtime/debug"
 	"strings"
 	"sync"
 
 	"github.com/tetratelabs/wazero/internal/buildoptions"
 	"github.com/tetratelabs/wazero/internal/moremath"
 	"github.com/tetratelabs/wazero/internal/wasm"
+	"github.com/tetratelabs/wazero/internal/wasmdebug"
 	"github.com/tetratelabs/wazero/internal/wasmruntime"
 	"github.com/tetratelabs/wazero/internal/wazeroir"
 )
@@ -516,28 +516,14 @@ func (me *moduleEngine) Call(m *wasm.ModuleContext, f *wasm.FunctionInstance, pa
 		// TODO: ^^ Will not fail if the function was imported from a closed module.
 
 		if v := recover(); v != nil {
-			if buildoptions.IsDebugMode {
-				debug.PrintStack()
-			}
-
-			traces := make([]string, len(ce.frames))
-			for i := 0; i < len(traces); i++ {
+			builder := wasmdebug.NewErrorBuilder()
+			frameCount := len(ce.frames)
+			for i := 0; i < frameCount; i++ {
 				frame := ce.popFrame()
-				name := frame.f.source.DebugName
-				// TODO: include DWARF symbols. See #58
-				traces[i] = fmt.Sprintf("\t%d: %s", i, name)
-			}
-
-			runtimeErr, ok := v.(error)
-			if ok {
-				err = fmt.Errorf("wasm runtime error: %w", runtimeErr)
-			} else {
-				err = fmt.Errorf("wasm runtime error: %v", v)
-			}
-
-			if len(traces) > 0 {
-				err = fmt.Errorf("%w\nwasm backtrace:\n%s", err, strings.Join(traces, "\n"))
+				fn := frame.f.source
+				builder.AddFrame(fn.DebugName, fn.ParamTypes(), fn.ResultTypes())
 			}
+			err = builder.FromRecovered(v)
 		}
 	}()
 
diff --git a/internal/wasm/jit/engine.go b/internal/wasm/jit/engine.go
index 603779e5..6d6618ff 100644
--- a/internal/wasm/jit/engine.go
+++ b/internal/wasm/jit/engine.go
@@ -5,13 +5,12 @@ import (
 	"math"
 	"reflect"
 	"runtime"
-	"runtime/debug"
-	"strings"
 	"sync"
 	"unsafe"
 
 	"github.com/tetratelabs/wazero/internal/buildoptions"
 	"github.com/tetratelabs/wazero/internal/wasm"
+	"github.com/tetratelabs/wazero/internal/wasmdebug"
 	"github.com/tetratelabs/wazero/internal/wasmruntime"
 	"github.com/tetratelabs/wazero/internal/wazeroir"
 )
@@ -481,32 +480,17 @@ func (me *moduleEngine) Call(m *wasm.ModuleContext, f *wasm.FunctionInstance, pa
 		// TODO: ^^ Will not fail if the function was imported from a closed module.
 
 		if v := recover(); v != nil {
-			if buildoptions.IsDebugMode {
-				debug.PrintStack()
-			}
-
-			var frames []string
+			builder := wasmdebug.NewErrorBuilder()
 			// Handle edge-case where the host function is called directly by Go.
 			if ce.globalContext.callFrameStackPointer == 0 {
 				fn := compiled.source
-				frames = append(frames, fmt.Sprintf("\t%d: %s", 0, fn.DebugName))
+				builder.AddFrame(fn.DebugName, fn.ParamTypes(), fn.ResultTypes())
 			}
 			for i := uint64(0); i < ce.globalContext.callFrameStackPointer; i++ {
-				f := ce.callFrameStack[ce.globalContext.callFrameStackPointer-1-i].compiledFunction
-				frames = append(frames, fmt.Sprintf("\t%d: %s", i, f.source.DebugName))
-				// TODO: include DWARF symbols. See #58
-			}
-
-			runtimeErr, ok := v.(error)
-			if ok {
-				err = fmt.Errorf("wasm runtime error: %w", runtimeErr)
-			} else {
-				err = fmt.Errorf("wasm runtime error: %v", v)
-			}
-
-			if len(frames) > 0 {
-				err = fmt.Errorf("%w\nwasm backtrace:\n%s", err, strings.Join(frames, "\n"))
+				fn := ce.callFrameStack[ce.globalContext.callFrameStackPointer-1-i].compiledFunction.source
+				builder.AddFrame(fn.DebugName, fn.ParamTypes(), fn.ResultTypes())
 			}
+			err = builder.FromRecovered(v)
 		}
 	}()
 
diff --git a/internal/wasm/module.go b/internal/wasm/module.go
index d3341761..36c2c4f0 100644
--- a/internal/wasm/module.go
+++ b/internal/wasm/module.go
@@ -11,6 +11,7 @@ import (
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/ieee754"
 	"github.com/tetratelabs/wazero/internal/leb128"
+	"github.com/tetratelabs/wazero/internal/wasmdebug"
 )
 
 // DecodeModule parses the configured source into a Module. This function returns when the source is exhausted or
@@ -456,7 +457,7 @@ func (m *Module) buildGlobals(importedGlobals []*GlobalInstance) (globals []*Glo
 	return
 }
 
-func (m *Module) buildFunctions() (functions []*FunctionInstance) {
+func (m *Module) buildFunctions(moduleName string) (functions []*FunctionInstance) {
 	var functionNames NameMap
 	if m.NameSection != nil {
 		functionNames = m.NameSection.FunctionNames
@@ -465,27 +466,28 @@ func (m *Module) buildFunctions() (functions []*FunctionInstance) {
 	importCount := m.ImportFuncCount()
 	n, nLen := 0, len(functionNames)
 	for codeIndex, typeIndex := range m.FunctionSection {
+		// The function name section begins with imports, but can be sparse. This keeps track of how far in the name
+		// section we've already searched.
 		funcIdx := importCount + uint32(codeIndex)
-		// Seek to see if there's a better name than "unknown"
-		name := "unknown"
+		var funcName string
 		for ; n < nLen; n++ {
 			next := functionNames[n]
 			if next.Index > funcIdx {
 				break // we have function names, but starting at a later index
 			} else if next.Index == funcIdx {
-				name = next.Name
+				funcName = next.Name
 				break
 			}
 		}
 
 		f := &FunctionInstance{
-			DebugName:  name,
 			Kind:       FunctionKindWasm,
 			Type:       m.TypeSection[typeIndex],
 			Body:       m.CodeSection[codeIndex].Body,
 			LocalTypes: m.CodeSection[codeIndex].LocalTypes,
-			Index:      importCount + uint32(codeIndex),
+			Index:      funcIdx,
 		}
+		f.DebugName = wasmdebug.FuncName(moduleName, funcName, funcIdx)
 		functions = append(functions, f)
 	}
 	return
diff --git a/internal/wasm/store.go b/internal/wasm/store.go
index 53fc3050..9e20bae4 100644
--- a/internal/wasm/store.go
+++ b/internal/wasm/store.go
@@ -87,7 +87,7 @@ type (
 	// FunctionInstance represents a function instance in a Store.
 	// See https://www.w3.org/TR/2019/REC-wasm-core-1-20191205/#function-instances%E2%91%A0
 	FunctionInstance struct {
-		// DebugName is for debugging purpose, and is used to augment the stack traces.
+		// DebugName is for debugging purpose, and is used to augment stack traces.
 		DebugName string
 
 		// Kind describes how this function should be called.
@@ -275,10 +275,10 @@ func (s *Store) Instantiate(ctx context.Context, module *Module, name string, sy
 	var funcSection SectionID
 	if module.HostFunctionSection == nil {
 		funcSection = SectionIDFunction
-		functions = module.buildFunctions()
+		functions = module.buildFunctions(name)
 	} else {
 		funcSection = SectionIDHostFunction
-		functions = module.buildHostFunctionInstances()
+		functions = module.buildHostFunctions(name)
 	}
 
 	// Now we have all instances from imports and local ones, so ready to create a new ModuleInstance.
diff --git a/internal/wasmdebug/debug.go b/internal/wasmdebug/debug.go
new file mode 100644
index 00000000..de7bf83f
--- /dev/null
+++ b/internal/wasmdebug/debug.go
@@ -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))
+}


Test patch:
diff --git a/internal/testing/enginetest/enginetest.go b/internal/testing/enginetest/enginetest.go
index e22e46df..082cd134 100644
--- a/internal/testing/enginetest/enginetest.go
+++ b/internal/testing/enginetest/enginetest.go
@@ -27,6 +27,7 @@ import (
 
 	"github.com/tetratelabs/wazero/api"
 	"github.com/tetratelabs/wazero/internal/wasm"
+	"github.com/tetratelabs/wazero/internal/wasmdebug"
 )
 
 type EngineTester interface {
@@ -310,70 +311,70 @@ func RunTestModuleEngine_Call_Errors(t *testing.T, et EngineTester) {
 			input:  []uint64{0},
 			module: imported.Ctx,
 			fn:     imported.Exports[wasmFnName].Function,
-			expectedErr: `wasm runtime error: integer divide by zero
-wasm backtrace:
-	0: wasm_div_by`,
+			expectedErr: `wasm error: integer divide by zero
+wasm stack trace:
+	imported.wasm_div_by(i32) i32`,
 		},
 		{
 			name:   "host function that panics",
 			input:  []uint64{math.MaxUint32},
 			module: imported.Ctx,
 			fn:     imported.Exports[hostFnName].Function,
-			expectedErr: `wasm runtime error: host-function panic
-wasm backtrace:
-	0: host_div_by`,
+			expectedErr: `host-function panic (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32`,
 		},
 		{
 			name:   "host function panics with runtime.Error",
 			input:  []uint64{0},
 			module: imported.Ctx,
-			fn:     imported.Exports[hostFnName].Function, // TODO: This should be a normal runtime error
-			expectedErr: `wasm runtime error: runtime error: integer divide by zero
-wasm backtrace:
-	0: host_div_by`,
+			fn:     imported.Exports[hostFnName].Function,
+			expectedErr: `runtime error: integer divide by zero (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32`,
 		},
 		{
 			name:   "wasm calls host function that panics",
 			input:  []uint64{math.MaxUint32},
 			module: imported.Ctx,
 			fn:     imported.Exports[callHostFnName].Function,
-			expectedErr: `wasm runtime error: host-function panic
-wasm backtrace:
-	0: host_div_by
-	1: call->host_div_by`,
+			expectedErr: `host-function panic (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32
+	imported.call->host_div_by(i32) i32`,
 		},
 		{
 			name:   "wasm calls imported wasm that calls host function panics with runtime.Error",
 			input:  []uint64{0},
 			module: importing.Ctx,
 			fn:     importing.Exports[callImportCallHostFnName].Function,
-			expectedErr: `wasm runtime error: runtime error: integer divide by zero
-wasm backtrace:
-	0: host_div_by
-	1: call->host_div_by
-	2: call_import->call->host_div_by`,
+			expectedErr: `runtime error: integer divide by zero (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32
+	imported.call->host_div_by(i32) i32
+	importing.call_import->call->host_div_by(i32) i32`,
 		},
 		{
 			name:   "wasm calls imported wasm that calls host function that panics",
 			input:  []uint64{math.MaxUint32},
 			module: importing.Ctx,
 			fn:     importing.Exports[callImportCallHostFnName].Function,
-			expectedErr: `wasm runtime error: host-function panic
-wasm backtrace:
-	0: host_div_by
-	1: call->host_div_by
-	2: call_import->call->host_div_by`,
+			expectedErr: `host-function panic (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32
+	imported.call->host_div_by(i32) i32
+	importing.call_import->call->host_div_by(i32) i32`,
 		},
 		{
 			name:   "wasm calls imported wasm calls host function panics with runtime.Error",
 			input:  []uint64{0},
 			module: importing.Ctx,
 			fn:     importing.Exports[callImportCallHostFnName].Function,
-			expectedErr: `wasm runtime error: runtime error: integer divide by zero
-wasm backtrace:
-	0: host_div_by
-	1: call->host_div_by
-	2: call_import->call->host_div_by`,
+			expectedErr: `runtime error: integer divide by zero (recovered by wazero)
+wasm stack trace:
+	imported.host_div_by(i32) i32
+	imported.call->host_div_by(i32) i32
+	importing.call_import->call->host_div_by(i32) i32`,
 		},
 	}
 	for _, tt := range tests {
@@ -433,7 +434,7 @@ func setupCallTests(t *testing.T, e wasm.Engine) (*wasm.ModuleInstance, wasm.Mod
 	}
 
 	// To use the function, we first need to add it to a module.
-	imported := &wasm.ModuleInstance{Name: t.Name()}
+	imported := &wasm.ModuleInstance{Name: "imported"}
 	addFunction(imported, wasmFnName, wasmFn)
 	addFunction(imported, hostFnName, hostFn)
 	addFunction(imported, callHostFnName, callHostFn)
@@ -444,7 +445,7 @@ func setupCallTests(t *testing.T, e wasm.Engine) (*wasm.ModuleInstance, wasm.Mod
 	linkModuleToEngine(imported, importedMe)
 
 	// To test stack traces, call the same function from another module
-	importing := &wasm.ModuleInstance{Name: t.Name() + "-importing"}
+	importing := &wasm.ModuleInstance{Name: "importing"}
 
 	// Don't add imported functions yet as NewModuleEngine requires them split.
 	importedFunctions := []*wasm.FunctionInstance{callHostFn}
@@ -484,7 +485,7 @@ func linkModuleToEngine(module *wasm.ModuleInstance, me wasm.ModuleEngine) {
 
 // addFunction assigns and adds a function to the module.
 func addFunction(module *wasm.ModuleInstance, funcName string, fn *wasm.FunctionInstance) {
-	fn.DebugName = funcName
+	fn.DebugName = wasmdebug.FuncName(module.Name, funcName, fn.Index)
 	module.Functions = append(module.Functions, fn)
 	if module.Exports == nil {
 		module.Exports = map[string]*wasm.ExportInstance{}
diff --git a/internal/wasm/module_test.go b/internal/wasm/module_test.go
index 6ca12278..51a1b0c7 100644
--- a/internal/wasm/module_test.go
+++ b/internal/wasm/module_test.go
@@ -701,11 +701,13 @@ func TestModule_buildFunctionInstances(t *testing.T) {
 				{Index: Index(5), Name: "five"},
 			},
 		},
-		CodeSection: []*Code{nopCode, nopCode, nopCode, nopCode, nopCode},
+		FunctionSection: []Index{0, 0, 0, 0, 0},
+		CodeSection:     []*Code{nopCode, nopCode, nopCode, nopCode, nopCode},
 	}
 
-	actual := m.buildFunctions()
-	expectedNames := []string{"unknown", "two", "unknown", "four", "five"}
+	// Note: This only returns module-defined functions, not imported ones. That's why the index starts with 1, not 0.
+	actual := m.buildFunctions("counter")
+	expectedNames := []string{"counter.[1]", "counter.two", "counter.[3]", "counter.four", "counter.five"}
 	for i, f := range actual {
 		require.Equal(t, expectedNames[i], f.DebugName)
 		require.Equal(t, nopCode.Body, f.Body)
diff --git a/internal/wasmdebug/debug_test.go b/internal/wasmdebug/debug_test.go
new file mode 100644
index 00000000..4b8fd649
--- /dev/null
+++ b/internal/wasmdebug/debug_test.go
@@ -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)
+}
diff --git a/tests/engine/adhoc_test.go b/tests/engine/adhoc_test.go
index 18a4d41a..ba835f27 100644
--- a/tests/engine/adhoc_test.go
+++ b/tests/engine/adhoc_test.go
@@ -85,12 +85,12 @@ func testUnreachable(t *testing.T, r wazero.Runtime) {
 	defer module.Close()
 
 	_, err = module.ExportedFunction("main").Call(nil)
-	exp := `wasm runtime error: panic in host function
-wasm backtrace:
-	0: cause_unreachable
-	1: two
-	2: one
-	3: main`
+	exp := `panic in host function (recovered by wazero)
+wasm stack trace:
+	host.cause_unreachable()
+	.two()
+	.one()
+	.main()`
 	require.Equal(t, exp, err.Error())
 }