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.
Run it
Section titled “Run it”The .wasm files are committed, so none of the three needs a toolchain installed. From
examples/allocation/tinygo:
$ go run greet.go wazywasm >> 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
The guest, three ways
Section titled “The guest, three ways”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:
(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.
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 logfunc _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 greetfunc _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 greetingfunc _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)}Built with:
cargo build --release --target wasm32-unknown-unknownRust exports nothing by default, so allocate and deallocate are written by hand at the bottom
of the file. The line that matters is std::mem::forget(g): the greeting is a local String, and
without forgetting it Rust drops it on the way out of the function and the host reads back a
corrupt value. Forgetting it moves the obligation to the host, which must call deallocate.
Cargo.toml pulls in wee_alloc and sets opt-level = "z" with LTO, which is why the artifact is
small.
8 collapsed lines
extern crate alloc;extern crate core;extern crate wee_alloc;
use alloc::vec::Vec;use std::mem::MaybeUninit;use std::slice;
/// Prints a greeting to the console using [`log`].fn greet(name: &String) { log(&["wasm >> ", &greeting(name)].concat());}
/// Gets a greeting for the name.fn greeting(name: &String) -> String { return ["Hello, ", &name, "!"].concat();}
18 collapsed lines
/// Logs a message to the console using [`_log`].fn log(message: &String) { unsafe { let (ptr, len) = string_to_ptr(message); _log(ptr, len); }}
#[link(wasm_import_module = "env")]extern "C" { /// WebAssembly import which prints a string (linear memory offset, /// byteCount) to the console. /// /// Note: This is not an ownership transfer: Rust still owns the pointer /// and ensures it isn't deallocated during this call. #[link_name = "log"] fn _log(ptr: u32, size: u32);}
/// WebAssembly export that accepts a string (linear memory offset, byteCount)/// and calls [`greet`].////// Note: The input parameters were returned by [`allocate`]. This is not an/// ownership transfer, so the inputs can be reused after this call.#[cfg_attr(all(target_arch = "wasm32"), export_name = "greet")]#[no_mangle]pub unsafe extern "C" fn _greet(ptr: u32, len: u32) { greet(&ptr_to_string(ptr, len));}
/// WebAssembly export that accepts a string (linear memory offset, byteCount)/// and returns a pointer/size pair packed into a u64.////// Note: The return value is leaked to the caller, so it must call/// [`deallocate`] when finished./// Note: This uses a u64 instead of two result values for compatibility with/// WebAssembly 1.0.#[cfg_attr(all(target_arch = "wasm32"), export_name = "greeting")]#[no_mangle]pub unsafe extern "C" fn _greeting(ptr: u32, len: u32) -> u64 { let name = &ptr_to_string(ptr, len); let g = greeting(name); let (ptr, len) = string_to_ptr(&g); // Note: This changes ownership of the pointer to the external caller. If // we didn't call forget, the caller would read back a corrupt value. Since // we call forget, the caller must deallocate externally to prevent leaks. std::mem::forget(g); return ((ptr as u64) << 32) | len as u64;}
20 collapsed lines
/// Returns a string from WebAssembly compatible numeric types representing/// its pointer and length.unsafe fn ptr_to_string(ptr: u32, len: u32) -> String { let slice = slice::from_raw_parts_mut(ptr as *mut u8, len as usize); let utf8 = std::str::from_utf8_unchecked_mut(slice); return String::from(utf8);}
/// Returns a pointer and size pair for the given string in a way compatible/// with WebAssembly numeric types.////// Note: This doesn't change the ownership of the String. To intentionally/// leak it, use [`std::mem::forget`] on the input after calling this.unsafe fn string_to_ptr(s: &String) -> (u32, u32) { return (s.as_ptr() as u32, s.len() as u32);}
/// Set the global allocator to the WebAssembly optimized one.#[global_allocator]static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
/// WebAssembly export that allocates a pointer (linear memory offset) that can/// be used for a string.////// This is an ownership transfer, which means the caller must call/// [`deallocate`] when finished.#[cfg_attr(all(target_arch = "wasm32"), export_name = "allocate")]#[no_mangle]pub extern "C" fn _allocate(size: u32) -> *mut u8 { allocate(size as usize)}
/// Allocates size bytes and leaks the pointer where they start.fn allocate(size: usize) -> *mut u8 { // Allocate the amount of bytes needed. let vec: Vec<MaybeUninit<u8>> = vec![MaybeUninit::uninit(); size];
// into_raw leaks the memory to the caller. Box::into_raw(vec.into_boxed_slice()) as *mut u8}
/// WebAssembly export that deallocates a pointer of the given size (linear/// memory offset, byteCount) allocated by [`allocate`].#[cfg_attr(all(target_arch = "wasm32"), export_name = "deallocate")]#[no_mangle]pub unsafe extern "C" fn _deallocate(ptr: u32, size: u32) { deallocate(ptr as *mut u8, size as usize);}
/// Retakes the pointer which allows its memory to be freed.unsafe fn deallocate(ptr: *mut u8, size: usize) { let _ = Vec::from_raw_parts(ptr, 0, size);}Built with zig build. The committed artifact comes from make build.examples.zig, which runs
zig build -Doptimize=ReleaseSmall inside testdata and moves the binary out of zig-out to sit
beside the source.
malloc and free are hand-written wrappers over std.heap.page_allocator, and free needs the
length back because a Zig allocator frees a slice, not a bare pointer. greeting returns 0 on
allocation failure rather than trapping, so a failure arrives as a value the host can test rather
than a trap it has to recover from. The Go side does test malloc’s result that way, and — as it
happens — not greeting’s. The example’s README notes it uses @panic() rather than
unreachable, since unreachable only emits a panic call in Debug and ReleaseSafe — in
ReleaseFast and ReleaseSmall it is undefined behaviour.
const std = @import("std");const allocator = std.heap.page_allocator;
extern "env" fn log(ptr: [*]const u8, size: u32) void;
// _log prints a message to the console using log.pub fn _log(message: []const u8) void { log(message.ptr, message.len);}
pub export fn malloc(length: usize) ?[*]u8 { const buff = allocator.alloc(u8, length) catch return null; return buff.ptr;}
pub export fn free(buf: [*]u8, length: usize) void { allocator.free(buf[0..length]);}
pub fn _greeting(name: []const u8) ![]u8 { return try std.fmt.allocPrint( allocator, "Hello, {s}!", .{name}, );}
// _greet prints a greeting to the console.pub fn _greet(name: []const u8) !void { const s = try std.fmt.allocPrint( allocator, "wasm >> {s}", .{name}, ); _log(s);}
// greet is a WebAssembly export that accepts a string pointer (linear memory offset) and calls greet.pub export fn greet(message: [*]const u8, size: u32) void { const name = _greeting(message[0..size]) catch |err| @panic(switch (err) { error.OutOfMemory => "out of memory", }); _greet(name) catch |err| @panic(switch (err) { error.OutOfMemory => "out of memory", });}
// 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.pub export fn greeting(message: [*]const u8, size: u32) u64 { const g = _greeting(message[0..size]) catch return 0; return stringToPtr(g);}
// stringToPtr returns a pointer and size pair for the given string in a way// compatible with WebAssembly numeric types.pub fn stringToPtr(s: []const u8) u64 { const p: u64 = @intFromPtr(s.ptr); return p << 32 | s.len;}The import all three share
Section titled “The import all three share”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:
// 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:
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.
Instantiation is where they stop agreeing
Section titled “Instantiation is where they stop agreeing”Only the TinyGo build needs WASI, and it needs a different start function. The other two are freestanding.
// 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.
// 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.Instantiate(ctx, greetWasm) if err != nil { log.Panicln(err) }wasm32-unknown-unknown links no WASI at all — env.log is the module’s only import — so there is
nothing to register beyond the env module, and nothing to configure: the default _start is
absent, and an absent start function is skipped in silence.
// 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().WithStdout(os.Stdout).WithStderr(os.Stderr)) if err != nil { return err }build.zig targets os_tag = .freestanding and sets lib.entry = .disabled, so again there is no
WASI and no entry point. The host still configures stdout and stderr, though a freestanding
build has no WASI functions to write through them.
Allocate, write, call
Section titled “Allocate, write, call”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.
// 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.
Reading back a pointer the guest owns
Section titled “Reading back a pointer the guest owns”greeting returns one i64 carrying two i32s. The TinyGo host also has to free what it reads:
// 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.
Ownership, packing, and one real leak
Section titled “Ownership, packing, and one real leak”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.
After the pointer arithmetic
Section titled “After the pointer arithmetic”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.