From 505afa3fec9bde4e1b454a20ddd5d52b745bb8bf Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Sat, 4 Apr 2026 23:22:11 -0300 Subject: [PATCH 1/3] Fix allocator lookup for anonymous WASM modules FunctionDefinition.Name() returns empty string for modules instantiated with WithName("") (anonymous), causing ExportedFunction("") to return nil even though cabi_realloc is exported. This silently broke all string parameter encoding for core WASM modules. Fix: use the export name map key instead of FunctionDefinition.Name() when looking up the allocator function. Also add null pointer guards on cabi_realloc return values to surface allocation failures early instead of passing ptr=0 to the WASM module, which causes NonNull::new_unchecked panics in Rust bindings. --- engine/wazero.go | 32 +++++++++++++++++++++++-------- linker/internal/memory/wrapper.go | 6 +++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/engine/wazero.go b/engine/wazero.go index 4cbfbef..f9be0ec 100644 --- a/engine/wazero.go +++ b/engine/wazero.go @@ -661,21 +661,29 @@ func (m *WazeroModule) InstantiateWithConfig(ctx context.Context, cfg *InstanceC wazInst.memory = &WazeroMemory{mem: mem} } - // Cache allocator - try standard cabi_realloc first, then fallbacks - allocFnDef := instance.ExportedFunctionDefinitions()[CabiRealloc] + // Cache allocator - try standard cabi_realloc first, then fallbacks. + // Use the map key (export name) for ExportedFunction lookup, not + // FunctionDefinition.Name() which returns empty for anonymous modules + // instantiated with WithName(""). + allExports := instance.ExportedFunctionDefinitions() + allocFnDef := allExports[CabiRealloc] + allocName := CabiRealloc if allocFnDef == nil { - allocFnDef = instance.ExportedFunctionDefinitions()[legacyRealloc] + allocFnDef = allExports[legacyRealloc] + allocName = legacyRealloc } if allocFnDef == nil { - allocFnDef = instance.ExportedFunctionDefinitions()[legacyAlloc] + allocFnDef = allExports[legacyAlloc] + allocName = legacyAlloc } if allocFnDef == nil { - allocFnDef = instance.ExportedFunctionDefinitions()[simpleAlloc] + allocFnDef = allExports[simpleAlloc] + allocName = simpleAlloc } var isSimpleAlloc bool if allocFnDef != nil { - wazInst.allocFn = instance.ExportedFunction(allocFnDef.Name()) + wazInst.allocFn = instance.ExportedFunction(allocName) paramCount := len(allocFnDef.ParamTypes()) isSimpleAlloc = paramCount < 4 } @@ -1070,7 +1078,11 @@ func (a *wazeroAllocator) Alloc(size, align uint32) (uint32, error) { if err != nil { return 0, err } - return uint32(a.stackBuf[0]), nil + ptr := uint32(a.stackBuf[0]) + if ptr == 0 && size > 0 { + return 0, fmt.Errorf("allocator returned null pointer (size=%d)", size) + } + return ptr, nil } a.stackBuf[0] = 0 a.stackBuf[1] = 0 @@ -1080,7 +1092,11 @@ func (a *wazeroAllocator) Alloc(size, align uint32) (uint32, error) { if err != nil { return 0, err } - return uint32(a.stackBuf[0]), nil + ptr := uint32(a.stackBuf[0]) + if ptr == 0 && size > 0 { + return 0, fmt.Errorf("cabi_realloc returned null pointer (size=%d, align=%d)", size, align) + } + return ptr, nil } func (a *wazeroAllocator) Free(ptr, size, align uint32) { diff --git a/linker/internal/memory/wrapper.go b/linker/internal/memory/wrapper.go index 86e3b28..df2f31a 100644 --- a/linker/internal/memory/wrapper.go +++ b/linker/internal/memory/wrapper.go @@ -130,7 +130,11 @@ func (a *AllocatorWrapper) Alloc(size, align uint32) (uint32, error) { if len(results) == 0 { return 0, fmt.Errorf("allocation returned no result") } - return uint32(results[0]), nil + ptr := uint32(results[0]) + if ptr == 0 && size > 0 { + return 0, fmt.Errorf("allocation returned null pointer (size=%d, align=%d)", size, align) + } + return ptr, nil } // Free deallocates memory using cabi_realloc. From a688ff7bbf61fbd72de424461a7bd171b99b12ff Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Sun, 5 Apr 2026 09:17:59 -0300 Subject: [PATCH 2/3] Fix pre-existing lint errors (nolintlint, staticcheck QF1012) --- cmd/run/interactive.go | 4 ++-- errors/errors.go | 2 +- transcoder/internal/types/kind_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/run/interactive.go b/cmd/run/interactive.go index 89b0404..36c340c 100644 --- a/cmd/run/interactive.go +++ b/cmd/run/interactive.go @@ -369,7 +369,7 @@ func (m *interactiveModel) View() string { case stateInputArgs: f := m.funcs[m.selected] - b.WriteString(fmt.Sprintf("Calling %s\n\n", funcStyle.Render(f.name))) + fmt.Fprintf(&b, "Calling %s\n\n", funcStyle.Render(f.name)) for i, input := range m.inputs { b.WriteString(input.View()) b.WriteString(" ") @@ -381,7 +381,7 @@ func (m *interactiveModel) View() string { case stateShowResult: f := m.funcs[m.selected] - b.WriteString(fmt.Sprintf("Result of %s:\n\n", funcStyle.Render(f.name))) + fmt.Fprintf(&b, "Result of %s:\n\n", funcStyle.Render(f.name)) if m.err != nil { b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err))) } else { diff --git a/errors/errors.go b/errors/errors.go index 7a6f00e..a05c177 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -421,7 +421,7 @@ func (e *MissingImportsError) Error() string { } var b strings.Builder - b.WriteString(fmt.Sprintf("missing %d host function(s):\n", len(e.Imports))) + fmt.Fprintf(&b, "missing %d host function(s):\n", len(e.Imports)) // Group by namespace for cleaner output byNS := make(map[string][]string) diff --git a/transcoder/internal/types/kind_test.go b/transcoder/internal/types/kind_test.go index a5e4608..098110d 100644 --- a/transcoder/internal/types/kind_test.go +++ b/transcoder/internal/types/kind_test.go @@ -1,4 +1,4 @@ -package types //nolint:revive // package name is used by internal consumers +package types import "testing" From 1188b40d61bc5ff178e327f5073e8f447a363505 Mon Sep 17 00:00:00 2001 From: Rodrigo Delduca Date: Sun, 5 Apr 2026 10:47:40 -0300 Subject: [PATCH 3/3] Add tests for allocator lookup fix and null pointer guards --- engine/allocator_lookup_test.go | 253 +++++++++++++++++++++++++ linker/internal/memory/wrapper_test.go | 55 ++++++ 2 files changed, 308 insertions(+) create mode 100644 engine/allocator_lookup_test.go diff --git a/engine/allocator_lookup_test.go b/engine/allocator_lookup_test.go new file mode 100644 index 0000000..dec74ea --- /dev/null +++ b/engine/allocator_lookup_test.go @@ -0,0 +1,253 @@ +package engine + +import ( + "context" + "testing" + + "github.com/wippyai/wasm-runtime/wat" + "go.bytecodealliance.org/wit" +) + +// WAT module that exports cabi_realloc + a string echo function. +// cabi_realloc: bump allocator using a global pointer. +// echo: copies (ptr, len) input to a return struct at a fixed address. +const echoWAT = `(module + (memory (export "memory") 4) + + ;; bump pointer starts after the first 1024 bytes (reserved for return values) + (global $bump (mut i32) (i32.const 1024)) + + ;; cabi_realloc(old_ptr, old_size, align, new_size) -> new_ptr + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) + (local $ptr i32) + (local.set $ptr (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get 3))) + (local.get $ptr) + ) + + ;; echo(ptr, len) -> retptr + ;; writes {ptr, len} at address 0 and returns 0 + (func (export "echo") (param $ptr i32) (param $len i32) (result i32) + (i32.store (i32.const 0) (local.get $ptr)) + (i32.store (i32.const 4) (local.get $len)) + (i32.const 0) + ) +)` + +// TestAllocatorLookup_AnonymousModule verifies that cabi_realloc is found +// when the module is instantiated with an empty name (anonymous module). +// This is the core bug: FunctionDefinition.Name() returns "" for anonymous +// modules, so ExportedFunction(def.Name()) returned nil. +func TestAllocatorLookup_AnonymousModule(t *testing.T) { + ctx := context.Background() + + wasmBytes, err := wat.Compile(echoWAT) + if err != nil { + t.Fatalf("compile WAT: %v", err) + } + + eng, err := NewWazeroEngine(ctx) + if err != nil { + t.Fatalf("NewWazeroEngine: %v", err) + } + defer eng.Close(ctx) + + mod, err := eng.LoadModule(ctx, wasmBytes) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + // Instantiate with empty name (anonymous) — this is how wippy creates instances + inst, err := mod.InstantiateWithConfig(ctx, &InstanceConfig{Name: ""}) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + defer inst.Close(ctx) + + if inst.allocFn == nil { + t.Fatal("allocFn is nil — cabi_realloc was not found for anonymous module") + } + + if inst.memory == nil { + t.Fatal("memory is nil") + } +} + +// TestAllocatorLookup_NamedModule verifies that cabi_realloc is found +// for a named module (control case). +func TestAllocatorLookup_NamedModule(t *testing.T) { + ctx := context.Background() + + wasmBytes, err := wat.Compile(echoWAT) + if err != nil { + t.Fatalf("compile WAT: %v", err) + } + + eng, err := NewWazeroEngine(ctx) + if err != nil { + t.Fatalf("NewWazeroEngine: %v", err) + } + defer eng.Close(ctx) + + mod, err := eng.LoadModule(ctx, wasmBytes) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + inst, err := mod.InstantiateWithConfig(ctx, &InstanceConfig{Name: "test-module"}) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + defer inst.Close(ctx) + + if inst.allocFn == nil { + t.Fatal("allocFn is nil — cabi_realloc was not found for named module") + } +} + +// TestCallWithTypes_StringParam_AnonymousModule verifies that string parameters +// can be encoded and passed to a WASM function in an anonymous module. +// This is the end-to-end test for the bug: without the fix, this fails with +// "failed to allocate N bytes for string data" because allocFn is nil. +func TestCallWithTypes_StringParam_AnonymousModule(t *testing.T) { + ctx := context.Background() + + wasmBytes, err := wat.Compile(echoWAT) + if err != nil { + t.Fatalf("compile WAT: %v", err) + } + + eng, err := NewWazeroEngine(ctx) + if err != nil { + t.Fatalf("NewWazeroEngine: %v", err) + } + defer eng.Close(ctx) + + mod, err := eng.LoadModule(ctx, wasmBytes) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + inst, err := mod.InstantiateWithConfig(ctx, &InstanceConfig{Name: ""}) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + defer inst.Close(ctx) + + // Call echo with a string parameter via CallWithTypes + paramTypes := []wit.Type{wit.String{}} + resultTypes := []wit.Type{wit.String{}} + + result, err := inst.CallWithTypes(ctx, "echo", paramTypes, resultTypes, "hello world") + if err != nil { + t.Fatalf("CallWithTypes failed: %v", err) + } + + s, ok := result.(string) + if !ok { + t.Fatalf("expected string result, got %T: %v", result, result) + } + if s != "hello world" { + t.Errorf("expected %q, got %q", "hello world", s) + } +} + +// TestAllocator_NullPointerCheck verifies that the allocator returns an error +// when cabi_realloc returns 0 for a non-zero allocation. +func TestAllocator_NullPointerCheck(t *testing.T) { + ctx := context.Background() + + // Module where cabi_realloc always returns 0 (simulates allocation failure) + nullAllocWAT := `(module + (memory (export "memory") 1) + (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) + (i32.const 0) + ) + )` + + wasmBytes, err := wat.Compile(nullAllocWAT) + if err != nil { + t.Fatalf("compile WAT: %v", err) + } + + eng, err := NewWazeroEngine(ctx) + if err != nil { + t.Fatalf("NewWazeroEngine: %v", err) + } + defer eng.Close(ctx) + + mod, err := eng.LoadModule(ctx, wasmBytes) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + inst, err := mod.InstantiateWithConfig(ctx, &InstanceConfig{Name: ""}) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + defer inst.Close(ctx) + + if inst.allocFn == nil { + t.Fatal("allocFn should not be nil") + } + + // Alloc with size > 0 should fail when cabi_realloc returns 0 + inst.alloc.setContext(ctx) + _, err = inst.alloc.Alloc(16, 1) + if err == nil { + t.Fatal("expected error for null pointer allocation") + } + + // Alloc with size == 0 should succeed (ptr=0 is valid for zero-size) + ptr, err := inst.alloc.Alloc(0, 1) + if err != nil { + t.Fatalf("zero-size alloc should succeed: %v", err) + } + if ptr != 0 { + t.Errorf("expected ptr=0 for zero-size alloc, got %d", ptr) + } +} + +// TestAllocatorLookup_MultipleInstances verifies that allocFn is correctly +// found for each new instance created from the same module. +func TestAllocatorLookup_MultipleInstances(t *testing.T) { + ctx := context.Background() + + wasmBytes, err := wat.Compile(echoWAT) + if err != nil { + t.Fatalf("compile WAT: %v", err) + } + + eng, err := NewWazeroEngine(ctx) + if err != nil { + t.Fatalf("NewWazeroEngine: %v", err) + } + defer eng.Close(ctx) + + mod, err := eng.LoadModule(ctx, wasmBytes) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + for i := 0; i < 5; i++ { + inst, err := mod.InstantiateWithConfig(ctx, &InstanceConfig{Name: ""}) + if err != nil { + t.Fatalf("Instantiate #%d: %v", i, err) + } + + if inst.allocFn == nil { + t.Fatalf("allocFn is nil on instance #%d", i) + } + + inst.alloc.setContext(ctx) + ptr, err := inst.alloc.Alloc(64, 1) + if err != nil { + t.Fatalf("Alloc on instance #%d: %v", i, err) + } + if ptr == 0 { + t.Fatalf("expected non-zero ptr on instance #%d", i) + } + + inst.Close(ctx) + } +} diff --git a/linker/internal/memory/wrapper_test.go b/linker/internal/memory/wrapper_test.go index 531387c..bba424a 100644 --- a/linker/internal/memory/wrapper_test.go +++ b/linker/internal/memory/wrapper_test.go @@ -2,6 +2,7 @@ package memory import ( "context" + "strings" "testing" "github.com/tetratelabs/wazero" @@ -191,3 +192,57 @@ func TestWrapper_IntegerReadWrite(t *testing.T) { t.Errorf("ReadU64: expected 0x123456789ABCDEF0, got 0x%x", v64) } } + +// nullAllocWASM is a module with cabi_realloc that always returns 0. +// (module (memory 1) (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) (i32.const 0))) +var nullAllocWASM = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x01, 0x60, 0x04, 0x7f, 0x7f, 0x7f, + 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x10, 0x01, 0x0c, + 0x63, 0x61, 0x62, 0x69, 0x5f, 0x72, 0x65, 0x61, 0x6c, 0x6c, 0x6f, 0x63, 0x00, 0x00, 0x0a, 0x06, + 0x01, 0x04, 0x00, 0x41, 0x00, 0x0b, +} + +func TestAllocatorWrapper_NullPointerReturnsError(t *testing.T) { + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + defer rt.Close(ctx) + + compiled, err := rt.CompileModule(ctx, nullAllocWASM) + if err != nil { + t.Fatalf("compile: %v", err) + } + + mod, err := rt.InstantiateModule(ctx, compiled, wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiate: %v", err) + } + defer mod.Close(ctx) + + fn := mod.ExportedFunction("cabi_realloc") + if fn == nil { + t.Fatal("cabi_realloc not found") + } + + alloc := WrapAllocator(ctx, fn) + if alloc == nil { + t.Fatal("expected non-nil allocator") + } + + // Non-zero size allocation returning ptr=0 should be an error + _, err = alloc.Alloc(64, 1) + if err == nil { + t.Fatal("expected error when cabi_realloc returns null for non-zero size") + } + if !strings.Contains(err.Error(), "null pointer") { + t.Errorf("error should mention null pointer, got: %v", err) + } + + // Zero-size allocation returning ptr=0 is valid + ptr, err := alloc.Alloc(0, 1) + if err != nil { + t.Fatalf("zero-size alloc should succeed: %v", err) + } + if ptr != 0 { + t.Errorf("expected ptr=0 for zero-size, got %d", ptr) + } +}