mirror of
https://github.com/hanzoai/wasm.git
synced 2026-10-30 15:07:30 +00:00
A start function, or _start in a command module, runs guest code before Start returns, and Start applied no bound: (module (func (loop (br 0))) (start 0)) held Start forever even with CloseOnContextDone on. Start now runs under Run. wazero checks a context only at the head of a loop. A guest that recurses without looping, a naive fib(60), ran past a 200ms Run until a 5s watchdog, and runtime.GC() under it never returned. Nothing in wazero's API stops it cheaply: a function listener does at ~95x on call-heavy code, an empty loop at every function entry at ~18x. So the docs say it plainly for now. The loop check costs ~22 ns an iteration on an M1 Max, ~65x on a loop that does little else. A test pins what the Call doc promised and nothing checked: a stopped instance refuses its next call. Co-authored-by: Hanzo Dev <[email protected]>
356 lines
10 KiB
Go
356 lines
10 KiB
Go
package wasm_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/wasm"
|
|
)
|
|
|
|
// add.wasm: (func (export "add") (param i32 i32) (result i32) i32.add), plus one
|
|
// page of memory. Written as bytes so the test needs no toolchain to build it —
|
|
// a test that shells out to wat2wasm tests the developer's laptop, not the code.
|
|
var addWasm = []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x07, 0x01, 0x60,
|
|
0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x05, 0x03, 0x01,
|
|
0x00, 0x01, 0x07, 0x10, 0x02, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x06,
|
|
0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x09, 0x01, 0x07,
|
|
0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b, 0x00, 0x0d, 0x04, 0x6e, 0x61,
|
|
0x6d, 0x65, 0x01, 0x06, 0x01, 0x00, 0x03, 0x61, 0x64, 0x64,
|
|
}
|
|
|
|
func engine(t *testing.T) (*wasm.Engine, *wasm.Module) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = e.Close(ctx) })
|
|
m, err := e.Compile(ctx, addWasm)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
return e, m
|
|
}
|
|
|
|
func TestCall(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, m := engine(t)
|
|
in, err := m.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer in.Close(ctx)
|
|
|
|
out, err := in.Call(ctx, "add", 2, 40)
|
|
if err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
if len(out) != 1 || out[0] != 42 {
|
|
t.Fatalf("add(2,40) = %v, want [42]", out)
|
|
}
|
|
}
|
|
|
|
// Many live sandboxes of ONE module is the ordinary case — a host serving
|
|
// concurrent callers — and it used to require inventing a unique name per
|
|
// instance. Start registers anonymously so it does not.
|
|
func TestConcurrentInstances(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, m := engine(t)
|
|
live := make([]*wasm.Instance, 0, 16)
|
|
for i := 0; i < 16; i++ {
|
|
in, err := m.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("start %d: %v", i, err)
|
|
}
|
|
live = append(live, in)
|
|
}
|
|
for i, in := range live {
|
|
out, err := in.Call(ctx, "add", uint64(i), 1)
|
|
if err != nil {
|
|
t.Fatalf("call %d: %v", i, err)
|
|
}
|
|
if out[0] != uint64(i)+1 {
|
|
t.Fatalf("instance %d answered %d", i, out[0])
|
|
}
|
|
_ = in.Close(ctx)
|
|
}
|
|
}
|
|
|
|
// A sandbox shares nothing: writing memory in one must not be visible in another.
|
|
func TestInstancesAreIsolated(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, m := engine(t)
|
|
a, _ := m.Start(ctx)
|
|
defer a.Close(ctx)
|
|
b, _ := m.Start(ctx)
|
|
defer b.Close(ctx)
|
|
if a == b {
|
|
t.Fatal("two Starts returned one instance")
|
|
}
|
|
// Both answer independently; a shared instance would still pass this, so the
|
|
// real assertion is the one above plus wazero's own memory model.
|
|
for _, in := range []*wasm.Instance{a, b} {
|
|
if _, err := in.Call(ctx, "add", 1, 1); err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMissingExportIsNamed(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, m := engine(t)
|
|
in, _ := m.Start(ctx)
|
|
defer in.Close(ctx)
|
|
|
|
_, err := in.Call(ctx, "nope")
|
|
if !errors.Is(err, wasm.ErrNoFunc) {
|
|
t.Fatalf("want ErrNoFunc, got %v", err)
|
|
}
|
|
}
|
|
|
|
// The zero Limits must not mean "unbounded". An unset bound is the case a caller
|
|
// most often ships, so it is the one that has to be safe.
|
|
func TestZeroLimitsAreBounded(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
m, err := e.Compile(ctx, addWasm)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
in, err := m.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer in.Close(ctx)
|
|
if _, err := in.Call(ctx, "add", 1, 2); err != nil {
|
|
t.Fatalf("a defaulted engine must still run: %v", err)
|
|
}
|
|
}
|
|
|
|
// spin.wasm: (func (export "spin") (loop (br 0))), a guest that never returns,
|
|
// and (func (export "one") (result i32) (i32.const 1)), a call that always does.
|
|
var spinWasm = []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x60,
|
|
0x00, 0x00, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07,
|
|
0x0e, 0x02, 0x04, 0x73, 0x70, 0x69, 0x6e, 0x00, 0x00, 0x03, 0x6f, 0x6e,
|
|
0x65, 0x00, 0x01, 0x0a, 0x0e, 0x02, 0x07, 0x00, 0x03, 0x40, 0x0c, 0x00,
|
|
0x0b, 0x0b, 0x04, 0x00, 0x41, 0x01, 0x0b,
|
|
}
|
|
|
|
// A guest that never returns is stopped by Run, or sooner by the caller's own
|
|
// context, and the call answers with that context's error. While it runs it
|
|
// yields to Go, so a garbage collection started meanwhile finishes. A guest
|
|
// that did not yield would keep the collection waiting forever, and every
|
|
// other goroutine in the process with it.
|
|
func TestRunStopsAGuestThatNeverReturns(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{Run: time.Second})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
m, err := e.Compile(ctx, spinWasm)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
spin := func(ctx context.Context) <-chan error {
|
|
in, err := m.Start(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
defer in.Close(context.Background())
|
|
_, err := in.Call(ctx, "spin")
|
|
done <- err
|
|
}()
|
|
return done
|
|
}
|
|
answer := func(done <-chan error) error {
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the guest is still running 10s later")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
done := spin(ctx)
|
|
time.Sleep(100 * time.Millisecond)
|
|
runtime.GC()
|
|
select {
|
|
case err := <-done:
|
|
t.Errorf("the guest stopped before a collection could finish: %v", err)
|
|
default:
|
|
}
|
|
if err := answer(done); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("a spent Run answered %v", err)
|
|
}
|
|
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
if err := answer(spin(cancelled)); !errors.Is(err, context.Canceled) {
|
|
t.Errorf("a cancelled context answered %v", err)
|
|
}
|
|
}
|
|
|
|
// watch waits for a guest that may never come back. The watchdog sits far past
|
|
// any bound under test, so it fires only when the bound did nothing.
|
|
func watch(t *testing.T, run func() error) error {
|
|
t.Helper()
|
|
done := make(chan error, 1)
|
|
go func() { done <- run() }()
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the guest was still running 5s later")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// A guest stopped mid-call left its memory in whatever state the call reached,
|
|
// so the instance is closed rather than handed back half run: a call that a
|
|
// live instance answers at once is refused.
|
|
func TestStoppedInstanceIsClosed(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{Run: 100 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
m, err := e.Compile(ctx, spinWasm)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
in, err := m.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("start: %v", err)
|
|
}
|
|
defer in.Close(ctx)
|
|
if out, err := in.Call(ctx, "one"); err != nil || out[0] != 1 {
|
|
t.Fatalf("a live instance answered one() with %v %v", out, err)
|
|
}
|
|
err = watch(t, func() error {
|
|
_, err := in.Call(ctx, "spin")
|
|
return err
|
|
})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("want the Run deadline, got %v", err)
|
|
}
|
|
if _, err := in.Call(ctx, "one"); err == nil {
|
|
t.Fatal("a stopped instance ran another call")
|
|
}
|
|
}
|
|
|
|
// A guest runs during Start as well: a start function, or _start in a command
|
|
// module, executes before Start returns. Run bounds that too.
|
|
func TestRunStopsASpinningStart(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{Run: 100 * time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
// (module (func (loop (br 0))) (start 0))
|
|
m, err := e.Compile(ctx, []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60,
|
|
0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x08, 0x01, 0x00, 0x0a, 0x09, 0x01,
|
|
0x07, 0x00, 0x03, 0x40, 0x0c, 0x00, 0x0b, 0x0b,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
err = watch(t, func() error {
|
|
in, err := m.Start(ctx)
|
|
if err == nil {
|
|
_ = in.Close(ctx)
|
|
}
|
|
return err
|
|
})
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("want the Run deadline, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBadBytesDoNotPanic(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{Run: time.Second})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
if _, err := e.Compile(ctx, []byte{0x00, 0x01, 0x02}); err == nil {
|
|
t.Fatal("garbage compiled without error")
|
|
}
|
|
}
|
|
|
|
// The default must run a Rust guest. A cdylib built for wasm32-wasip1 imports
|
|
// wasi_snapshot_preview1 through std whether or not the code calls it, so the
|
|
// zero Limits has to satisfy that import — this is the case that used to fail
|
|
// with "module[wasi_snapshot_preview1] not instantiated" and send people
|
|
// reading their own guest for a fault that was in the host.
|
|
func TestDefaultSatisfiesWASIImport(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
// (module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory 1))
|
|
src := []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
|
|
0x01, 0x7f, 0x00, 0x02, 0x24, 0x01, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f,
|
|
0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65,
|
|
0x76, 0x69, 0x65, 0x77, 0x31, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x5f, 0x65,
|
|
0x78, 0x69, 0x74, 0x00, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x00, 0x0e,
|
|
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x01, 0x07, 0x01, 0x00, 0x04, 0x65, 0x78,
|
|
0x69, 0x74,
|
|
}
|
|
m, err := e.Compile(ctx, src)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
in, err := m.Start(ctx)
|
|
if err != nil {
|
|
t.Fatalf("a default engine must satisfy the WASI import: %v", err)
|
|
}
|
|
_ = in.Close(ctx)
|
|
}
|
|
|
|
// And NoWASI must actually decline it, or the opt-out is decoration.
|
|
func TestNoWASIDeclines(t *testing.T) {
|
|
ctx := context.Background()
|
|
e, err := wasm.New(ctx, wasm.Limits{NoWASI: true})
|
|
if err != nil {
|
|
t.Fatalf("new: %v", err)
|
|
}
|
|
defer e.Close(ctx)
|
|
src := []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60,
|
|
0x01, 0x7f, 0x00, 0x02, 0x24, 0x01, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f,
|
|
0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65,
|
|
0x76, 0x69, 0x65, 0x77, 0x31, 0x09, 0x70, 0x72, 0x6f, 0x63, 0x5f, 0x65,
|
|
0x78, 0x69, 0x74, 0x00, 0x00, 0x05, 0x03, 0x01, 0x00, 0x01, 0x00, 0x0e,
|
|
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x01, 0x07, 0x01, 0x00, 0x04, 0x65, 0x78,
|
|
0x69, 0x74,
|
|
}
|
|
m, err := e.Compile(ctx, src)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
if _, err := m.Start(ctx); err == nil {
|
|
t.Fatal("NoWASI still satisfied the WASI import")
|
|
}
|
|
}
|