Skip to content

Strings across the boundary

A WebAssembly function signature holds numbers. There is no way to declare one that takes a string, so the guest exports greet(ptr, len) and the host writes the bytes into the guest’s linear memory before it calls. The interesting part is not the encoding — it is that the allocation now has two owners, and the specification says nothing about which of them frees it.

The same program is built three times here, from TinyGo, Rust and Zig. The Go host is nearly identical in all three. The allocator contract is not.

The .wasm files are committed, so none of the three needs a toolchain installed. From examples/allocation/tinygo:

Terminal window
$ go run greet.go wazy
wasm >> Hello, wazy!
go >> Hello, wazy!

The first line was printed by the guest, through a host function you gave it. The second is Go reading back a string the guest allocated and handed over as a pointer. examples/allocation/rust and examples/allocation/zig answer the same command with the same two lines.

  • Directoryexamples/allocation/
    • README.md
    • Directorytinygo/
      • greet.go the host
      • Directorytestdata/
        • greet.go the guest
        • greet.wasm
    • Directoryrust/
      • greet.go
      • Directorytestdata/
        • Cargo.toml
        • greet.rs
        • greet.wasm
    • Directoryzig/
      • greet.go
      • Directorytestdata/
        • build.zig
        • greet.zig
        • greet.wasm

Every guest exports the same four things: greet, which logs a greeting through a host import; greeting, which returns one; and a pair of allocation functions. Where they differ is whether those allocation functions came free with the toolchain, and what it takes to stop the language’s memory management from reclaiming a buffer the host still holds.

Built with:

Terminal window
(cd testdata; tinygo build -scheduler=none -target=wasip1 -buildmode=c-shared -o greet.wasm greet.go)

TinyGo is the only one of the three that exports malloc and free on its own — the host’s comment calls them “undocumented, but exported”, citing tinygo-org/tinygo#2788. The cost shows up on the way out: a Go string lives in memory TinyGo manages, so stringToLeakedPtr copies it into a C.malloc block first. That is what the import "C" at the top is for.

examples/allocation/tinygo/testdata/greet.go
13 collapsed lines
package main
// #include <stdlib.h>
import "C"
import (
"fmt"
"runtime"
"unsafe"
)
// main is required for TinyGo to compile to Wasm.
func main() {}
// greet prints a greeting to the console.
func greet(name string) {
log(fmt.Sprint("wasm >> ", greeting(name)))
}
// log a message to the console using _log.
func log(message string) {
ptr, size := stringToPtr(message)
_log(ptr, size)
runtime.KeepAlive(message) // keep message alive until ptr is no longer needed.
}
// _log is a WebAssembly import which prints a string (linear memory offset,
// byteCount) to the console.
//
//go:wasmimport env log
func _log(ptr, size uint32)
// greeting gets a greeting for the name.
func greeting(name string) string {
return fmt.Sprint("Hello, ", name, "!")
}
// _greet is a WebAssembly export that accepts a string pointer (linear memory
// offset) and calls greet.
//
//go:wasmexport greet
func _greet(ptr, size uint32) {
name := ptrToString(ptr, size)
greet(name)
}
// _greeting is a WebAssembly export that accepts a string pointer (linear memory
// offset) and returns a pointer/size pair packed into a uint64.
//
// Note: This uses a uint64 instead of two result values for compatibility with
// WebAssembly 1.0.
//
//go:wasmexport greeting
func _greeting(ptr, size uint32) (ptrSize uint64) {
name := ptrToString(ptr, size)
g := greeting(name)
ptr, size = stringToLeakedPtr(g)
return (uint64(ptr) << uint64(32)) | uint64(size)
}
15 collapsed lines
// ptrToString returns a string from WebAssembly compatible numeric types
// representing its pointer and length.
func ptrToString(ptr uint32, size uint32) string {
return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), size)
}
// stringToPtr returns a pointer and size pair for the given string in a way
// compatible with WebAssembly numeric types.
// The returned pointer aliases the string hence the string must be kept alive
// until ptr is no longer needed.
func stringToPtr(s string) (uint32, uint32) {
ptr := unsafe.Pointer(unsafe.StringData(s))
return uint32(uintptr(ptr)), uint32(len(s))
}
// stringToLeakedPtr returns a pointer and size pair for the given string in a way
// compatible with WebAssembly numeric types.
// The pointer is not automatically managed by TinyGo hence it must be freed by the host.
func stringToLeakedPtr(s string) (uint32, uint32) {
size := C.ulong(len(s))
ptr := unsafe.Pointer(C.malloc(size))
copy(unsafe.Slice((*byte)(ptr), size), s)
return uint32(uintptr(ptr)), uint32(size)
}

The guest cannot print. It calls an import named log in a module named env, with a pointer and a byte count, and the host does the reading. The TinyGo and Rust hosts write this block identically; the Zig one differs by a single line, returning the error where the others panic:

examples/allocation/rust/greet.go
// Instantiate a Go-defined module named "env" that exports a function to
// log to the console.
envBuilder := r.NewHostModuleBuilder("env")
wazy.HostProc2(envBuilder.NewFunctionBuilder(), logString).Export("log")
_, err := envBuilder.Instantiate(ctx)
if err != nil {
log.Panicln(err)
}

HostProc2 builds a host function from a Go func taking two parameters and returning nothing, deriving the Wasm signature from the Go types — a “proc” is the no-result form; HostFunc2 is the one that returns a value. Nothing about it is string-shaped. It receives two uint32 and decides for itself what they mean:

examples/allocation/rust/greet.go
func logString(ctx context.Context, m api.Module, offset, byteCount uint32) {
buf, ok := m.Memory().Read(offset, byteCount)
if !ok {
log.Panicf("Memory.Read(%d, %d) out of range", offset, byteCount)
}
fmt.Println(string(buf))
}

Memory().Read hands back a view over the guest’s memory rather than a copy, so writing through that slice is visible to Wasm. string(buf) copies, which is what makes it safe to hold onto after the call returns.

Only the TinyGo build needs WASI, and it needs a different start function. The other two are freestanding.

examples/allocation/tinygo/greet.go
// Note: testdata/greet.go doesn't use WASI, but TinyGo needs it to
// implement functions such as panic.
wasi_snapshot_preview1.MustInstantiate(ctx, r)
// Instantiate a WebAssembly module that imports the "log" function defined
// in "env" and exports "memory" and functions we'll use in this example.
mod, err := r.InstantiateWithConfig(ctx, greetWasm, wazy.NewModuleConfig().WithStartFunctions("_initialize"))
if err != nil {
log.Panicln(err)
}

Both lines are inherited from Adding two numbers: the guest never calls a WASI function itself, but TinyGo links one in for panic, and a c-shared build has an _initialize where ModuleConfig would look for a _start. What is new here is that the other two guests need neither.

Getting the name in is the same shape everywhere: ask the guest for memory, write into the offset it returns, then pass that offset and the length as the two arguments.

examples/allocation/rust/greet.go
// Get references to WebAssembly functions we'll use in this example.
greet := mod.ExportedFunction("greet")
greeting := mod.ExportedFunction("greeting")
allocate := mod.ExportedFunction("allocate")
deallocate := mod.ExportedFunction("deallocate")
// Let's use the argument to this main function in Wasm.
name := os.Args[1]
nameSize := uint64(len(name))
// Instead of an arbitrary memory offset, use Rust's allocator. Notice
// there is nothing string-specific in this allocation function. The same
// function could be used to pass binary serialized data to Wasm.
results, err := allocate.Call(ctx, nameSize)
if err != nil {
log.Panicln(err)
}
namePtr := results[0]
// This pointer was allocated by Rust, but owned by Go, So, we have to
// deallocate it when finished
defer deallocate.Call(ctx, namePtr, nameSize)
// The pointer is a linear memory offset, which is where we write the name.
if !mod.Memory().Write(uint32(namePtr), []byte(name)) {
log.Panicf("Memory.Write(%d, %d) out of range of memory size %d",
namePtr, nameSize, mod.Memory().Size())
}
// Now, we can call "greet", which reads the string we wrote to memory!
_, err = greet.Call(ctx, namePtr, nameSize)
if err != nil {
log.Panicln(err)
}

The names and signatures are the part that does not carry over between toolchains:

allocator exports how the host frees who wrote them
TinyGo malloc / free free(ptr) the toolchain
Rust allocate / deallocate deallocate(ptr, size) the example
Zig malloc / free free(ptr, size) the example

TinyGo’s free is C’s free, which takes a pointer alone. Rust and Zig both free a sized region, so the host has to remember the length it asked for. Get that wrong and you corrupt the guest’s heap rather than get an error.

greeting returns one i64 carrying two i32s. The TinyGo host also has to free what it reads:

examples/allocation/tinygo/greet.go
// Finally, we get the greeting message "greet" printed. This shows how to
// read-back something allocated by TinyGo.
ptrSize, err := greeting.Call(ctx, namePtr, nameSize)
if err != nil {
log.Panicln(err)
}
greetingPtr := uint32(ptrSize[0] >> 32)
greetingSize := uint32(ptrSize[0])
// This pointer is managed by TinyGo, but TinyGo is unaware of external usage.
// So, we have to free it when finished
if greetingPtr != 0 {
defer func() {
_, err := free.Call(ctx, uint64(greetingPtr))
if err != nil {
log.Panicln(err)
}
}()
}
// The pointer is a linear memory offset, which is where we write the name.
if bytes, ok := mod.Memory().Read(greetingPtr, greetingSize); !ok {
log.Panicf("Memory.Read(%d, %d) out of range of memory size %d",
greetingPtr, greetingSize, mod.Memory().Size())
} else {
fmt.Println("go >>", string(bytes))
}

The guard on greetingPtr != 0 is not defensive noise — a guest that failed to allocate returns a zero pointer, and freeing that would be a second bug on top of the first.

The packed u64 is a workaround, not a convention. Multiple return values are a post-1.0 Wasm feature, and every one of these guests notes in a comment that it packs the pair into a single u64 for compatibility. The host unpacks with ptrSize[0] >> 32 for the pointer and a truncating uint32(ptrSize[0]) for the length. Both halves have to agree on the order, and nothing checks that they do.

Ownership is written in comments. Delete std::mem::forget(g) from the Rust guest and the host reads freed memory. Have the TinyGo guest return a pointer into its own heap instead of a C.malloc copy and the same thing happens later, non-deterministically. Neither change breaks a type; the compiler has nothing to check.

A memory view can go stale. Memory().Read returns a slice aliasing the guest’s memory, and it disconnects on any capacity change — a memory.grow in the guest leaves the host holding a slice onto the old backing array. Copy what you need before calling back into Wasm, or give the guest a memory whose minimum equals its maximum so there is nothing to grow.

The Zig host leaks the greeting. It frees the name it allocated but never frees the pointer greeting returned, unlike the TinyGo and Rust hosts, which both defer a free. In a program that prints one line and exits this is invisible. In a server calling greeting per request it is a page allocator’s worth of growth per call.

The immediate sequel reverses the arrow. Calling Go from Wasm is the guest declaring the import and you writing the function that answers it — the env.log module above, built properly, with the typed helpers instead of a raw stack.

Further out, the Component Model exists because of this page. A component declares greet: func(name: string) in WIT, and the canonical ABI settles allocation, ownership and encoding once, for every language pair — no malloc export, no packed u64, no comment explaining who frees what. The Go side receives a string.