diff --git a/packages/go/go.mod b/packages/go/go.mod index 6d67e135..b9cab1ec 100644 --- a/packages/go/go.mod +++ b/packages/go/go.mod @@ -18,6 +18,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/minio v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 + github.com/tetratelabs/wazero v1.11.0 github.com/wI2L/jsondiff v0.7.1 golang.org/x/crypto v0.51.0 golang.org/x/oauth2 v0.36.0 diff --git a/packages/go/go.sum b/packages/go/go.sum index 3fe13639..3d41839f 100644 --- a/packages/go/go.sum +++ b/packages/go/go.sum @@ -180,6 +180,8 @@ github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndr github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg= github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY= +github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/packages/go/plugins/runtime/doc.go b/packages/go/plugins/runtime/doc.go new file mode 100644 index 00000000..98c9d263 --- /dev/null +++ b/packages/go/plugins/runtime/doc.go @@ -0,0 +1,94 @@ +// Package runtime is the WebAssembly host for GoNext plugins. +// +// Every plugin in GoNext ships as a WebAssembly module. The runtime +// package owns the hostbound half of that contract: it compiles the +// module's bytes, instantiates them inside an isolated wazero context, +// exposes a small set of host functions the guest can call back into +// (logging, panic, monotonic time), and lets host code invoke the +// module's exports. +// +// This is the FOUNDATION layer — issue #6 in the GoNext tracker. It +// deliberately stays small and unopinionated so the things that build +// on top of it can do so without fighting the abstractions: +// +// - Instance pooling (issue #9) — the pool is a sidecar over Runtime +// that holds N pre-instantiated Modules and hands them out to +// callers. Nothing in this package precludes that; pools just call +// LoadModule N times with the same bytes. +// +// - Resource limits & fuel (issue #15) — the per-module hard caps +// here (16 MiB memory) are placeholders. The real limit story uses +// wazero's WithMemoryLimitPages + WithCloseOnContextDone + a fuel +// meter the host injects. The Module type exposes its underlying +// wazero handles via Module.WazeroModule so #15 can wire those +// without refactoring this package. +// +// - Capability ABI (issue #107) — the host functions registered in +// host.go are the BARE MINIMUM the guest needs to be useful +// (log, panic, time). The real capability surface (http.outbound, +// kv.read/write, db.query, email.send, ...) is much larger and +// lives in its own package once #107 lands. The capability host is +// a HostBuilder seam: the Runtime accepts additional host modules +// via WithHostModule, so the capability package can register its +// own functions without modifying this package. +// +// # Threading model +// +// wazero is goroutine-safe at the runtime layer (multiple modules can +// be loaded concurrently) but a single api.Function.Call is NOT +// goroutine-safe — concurrent callers of the SAME exported function on +// the SAME module must serialize. +// +// The Module type in this package enforces that contract with a +// per-module sync.Mutex around every Call invocation. Concurrent Call +// from N goroutines on the same Module is therefore safe — it just +// serializes. Pool-based callers (#9) that want true parallelism keep +// N Modules and route calls round-robin. +// +// # Trap handling +// +// A WASM trap (host function panic, division by zero, out-of-bounds +// memory access, etc.) surfaces as a Go error from Module.Call. The +// runtime catches the trap, drains any partial stack, and returns a +// *TrapError wrapping the original cause. The module is left in its +// pre-trap state — wazero modules are not poisoned by traps unless the +// host explicitly closes them. Callers that want fail-fast semantics +// (drop the module after any trap) implement that policy themselves. +// +// gn_panic explicitly traps with a *TrapError carrying the guest's +// panic message decoded from linear memory. +// +// # Host function ABI +// +// Host functions live in the "env" namespace, matching the convention +// most WASM toolchains (Rust, AssemblyScript, TinyGo) emit imports +// under by default. The minimum surface registered here: +// +// env.gn_log(level i32, ptr i32, len i32) +// env.gn_panic(ptr i32, len i32) // never returns +// env.gn_time_ms() -> i64 +// +// Strings are passed as (ptr, len) pairs into the guest's exported +// linear memory. The guest is expected to export a memory named +// "memory" (the universal default). If a guest doesn't export memory, +// calling a host function that reads memory returns a *HostError. +// +// # Typical wiring +// +// rt, err := runtime.New(ctx, +// runtime.WithLogger(logger), +// runtime.WithTimeSource(time.Now), +// ) +// if err != nil { return err } +// defer rt.Close(ctx) +// +// mod, err := rt.LoadModule(ctx, "blog-stats", wasmBytes) +// if err != nil { return err } +// defer mod.Close(ctx) +// +// results, err := mod.Call(ctx, "on_activate") +// if err != nil { ... } +// +// See packages/go/plugins/lifecycle for the state-machine half; the +// lifecycle Manager injects a Runtime adapter that wraps this package. +package runtime diff --git a/packages/go/plugins/runtime/errors.go b/packages/go/plugins/runtime/errors.go new file mode 100644 index 00000000..6bc4e17f --- /dev/null +++ b/packages/go/plugins/runtime/errors.go @@ -0,0 +1,92 @@ +package runtime + +import ( + "errors" + "fmt" +) + +// ErrModuleClosed is returned by Module.Call when the underlying wazero +// module has already been closed. Callers can match with errors.Is. +var ErrModuleClosed = errors.New("runtime: module is closed") + +// ErrRuntimeClosed is returned by Runtime.LoadModule when the Runtime +// itself has been closed. +var ErrRuntimeClosed = errors.New("runtime: runtime is closed") + +// ErrFunctionNotFound is returned by Module.Call when the requested +// export name does not exist on the module, or exists but is not a +// function. +var ErrFunctionNotFound = errors.New("runtime: exported function not found") + +// ErrMemoryNotExported is returned by host functions (and by +// Module.Memory) when the guest module did not export a linear memory. +// Guest authors that want to use any host function which reads/writes +// strings (gn_log, gn_panic) MUST export `memory`. +var ErrMemoryNotExported = errors.New("runtime: module did not export linear memory") + +// TrapError wraps a WebAssembly trap surfaced through wazero — a guest +// panic via gn_panic, division by zero, out-of-bounds memory access, +// stack exhaustion, etc. +// +// Reason is the human-readable description of the trap. For +// gn_panic-originated traps it is the message the guest passed in. For +// wazero-originated traps it is the wazero error string. +// +// Module is the name of the module that trapped, populated so a caller +// receiving a TrapError without context can attribute it. +// +// The underlying wazero error (when present) is unwrappable via +// errors.Unwrap for callers that need to inspect *sys.ExitError or +// other wazero-internal types. +type TrapError struct { + Module string + Reason string + Cause error +} + +// Error returns a one-line description of the trap. +func (e *TrapError) Error() string { + if e.Module == "" { + return fmt.Sprintf("runtime: wasm trap: %s", e.Reason) + } + return fmt.Sprintf("runtime: wasm trap in %q: %s", e.Module, e.Reason) +} + +// Unwrap returns the underlying wazero error, if any. +func (e *TrapError) Unwrap() error { return e.Cause } + +// HostError is returned by a host function that could not satisfy a +// guest call (e.g., the guest asked gn_log to read from an out-of-bounds +// memory address). The host function records the HostError and traps +// the guest so the failure propagates as a TrapError to Module.Call. +// +// HostError is exported so test code that drives host functions +// directly can match the value. +type HostError struct { + Function string + Reason string +} + +// Error returns a one-line description of the host error. +func (e *HostError) Error() string { + return fmt.Sprintf("runtime: host function %s: %s", e.Function, e.Reason) +} + +// CompileError wraps a wazero CompileModule failure. The bytes were +// rejected as not being a valid WebAssembly binary. The Cause is the +// wazero error string for diagnostic logs. +type CompileError struct { + Module string + Cause error +} + +// Error returns a one-line description of the compile failure. +func (e *CompileError) Error() string { + if e.Module == "" { + return fmt.Sprintf("runtime: compile failed: %v", e.Cause) + } + return fmt.Sprintf("runtime: compile %q failed: %v", e.Module, e.Cause) +} + +// Unwrap returns the underlying wazero error. +func (e *CompileError) Unwrap() error { return e.Cause } diff --git a/packages/go/plugins/runtime/host.go b/packages/go/plugins/runtime/host.go new file mode 100644 index 00000000..220cae21 --- /dev/null +++ b/packages/go/plugins/runtime/host.go @@ -0,0 +1,241 @@ +package runtime + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/tetratelabs/wazero/api" +) + +// Log severity levels used by gn_log. Mirroring slog's levels keeps +// the host-side mapping trivial. +const ( + logLevelDebug int32 = 0 + logLevelInfo int32 = 1 + logLevelWarn int32 = 2 + logLevelError int32 = 3 +) + +// maxHostStringLen is the cap on bytes a host function will read from +// guest linear memory in a single (ptr, len) pair. 64 KiB is generous +// for log messages and panic reasons; anything larger is almost +// certainly a malformed pointer. +const maxHostStringLen = 64 * 1024 + +// panicRecorder is the per-Call container for whatever the guest passed +// to gn_panic before unwinding. We can't return data out of a trapping +// host function via normal returns, so we stash it on the ctx-attached +// recorder and pluck it out on the way up. +type panicRecorder struct { + mu sync.Mutex + reason string +} + +// record stores the panic reason. Safe to call from any goroutine +// because Module.Call serializes invocations on a per-module mutex — +// but we still use a fine-grained lock here so a future change to +// drop the per-module serialization (e.g. when wazero gains a thread +// model) doesn't introduce a quiet race. +func (p *panicRecorder) record(reason string) { + p.mu.Lock() + defer p.mu.Unlock() + p.reason = reason +} + +// registerEnvHost wires the built-in "env" host module onto rt's +// wazero runtime. +// +// Functions registered: +// +// gn_log(level i32, ptr i32, len i32) — write a host-side log line +// gn_panic(ptr i32, len i32) — trap with a guest message +// gn_time_ms() -> i64 — monotonic-ish wall-clock ms +// +// gn_log and gn_panic both read their string argument from the calling +// module's exported linear memory at [ptr, ptr+len). If the read is +// out of bounds or no memory is exported, the host function records a +// HostError and traps the guest. That way a buggy guest gets a clear +// trap rather than silent UB. +func (r *Runtime) registerEnvHost(ctx context.Context) error { + b := r.wazeroRT.NewHostModuleBuilder(hostModuleName) + + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(r.hostGnLog), + []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32}, + nil). + WithParameterNames("level", "ptr", "len"). + Export("gn_log") + + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(r.hostGnPanic), + []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, + nil). + WithParameterNames("ptr", "len"). + Export("gn_panic") + + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(r.hostGnTimeMs), + nil, + []api.ValueType{api.ValueTypeI64}). + Export("gn_time_ms") + + if _, err := b.Instantiate(ctx); err != nil { + return fmt.Errorf("instantiate %q host module: %w", hostModuleName, err) + } + return nil +} + +// readHostString reads [ptr, ptr+length) out of the calling module's +// memory and returns the byte slice. The slice points into wazero's +// underlying buffer — callers that need to retain the data past the +// host call must copy it. +// +// Returns nil, *HostError on out-of-bounds, missing memory, or +// pathologically-long requests. +func readHostString(fnName string, mod api.Module, ptr, length uint32) ([]byte, error) { + if length == 0 { + return nil, nil + } + if length > maxHostStringLen { + return nil, &HostError{ + Function: fnName, + Reason: fmt.Sprintf("string length %d exceeds host cap %d", length, maxHostStringLen), + } + } + mem := mod.Memory() + if mem == nil { + return nil, &HostError{Function: fnName, Reason: ErrMemoryNotExported.Error()} + } + buf, ok := mem.Read(ptr, length) + if !ok { + return nil, &HostError{ + Function: fnName, + Reason: fmt.Sprintf("memory read [%d..%d) out of bounds (size=%d)", ptr, ptr+length, mem.Size()), + } + } + return buf, nil +} + +// hostGnLog implements env.gn_log. Signature: (level i32, ptr i32, len i32). +// +// The level argument maps onto slog's levels (debug/info/warn/error); +// unknown levels are routed to info. Out-of-bounds string reads emit a +// host-side warning but DO NOT trap — log calls are intentionally +// best-effort so a misbehaving plugin can't bring the host down by +// logging junk. +func (r *Runtime) hostGnLog(ctx context.Context, mod api.Module, stack []uint64) { + level := api.DecodeI32(stack[0]) + ptr := api.DecodeU32(stack[1]) + length := api.DecodeU32(stack[2]) + + buf, err := readHostString("gn_log", mod, ptr, length) + if err != nil { + r.logger.Warn("runtime: gn_log: bad string args", + slog.String("module", mod.Name()), + slog.String("err", err.Error())) + return + } + + msg := string(buf) + switch level { + case logLevelDebug: + r.logger.Debug(msg, slog.String("plugin", mod.Name())) + case logLevelWarn: + r.logger.Warn(msg, slog.String("plugin", mod.Name())) + case logLevelError: + r.logger.Error(msg, slog.String("plugin", mod.Name())) + case logLevelInfo: + fallthrough + default: + r.logger.Info(msg, slog.String("plugin", mod.Name())) + } +} + +// hostGnPanic implements env.gn_panic. Signature: (ptr i32, len i32). +// +// The host reads the panic reason from guest memory, attaches it to a +// recorder, and CLOSES the calling module with a non-zero exit code. +// That close turns into a *sys.ExitError on the caller's Call(), which +// classifyCallError can promote into a *TrapError carrying the +// recorded reason. +// +// Why close instead of letting wazero unwind a trap naturally? wazero +// host functions don't have a "trap immediately" primitive that +// preserves a custom error payload; CloseWithExitCode is the documented +// way to terminate the guest from inside a host call. +func (r *Runtime) hostGnPanic(ctx context.Context, mod api.Module, stack []uint64) { + ptr := api.DecodeU32(stack[0]) + length := api.DecodeU32(stack[1]) + + rec := &panicRecorder{} + if buf, err := readHostString("gn_panic", mod, ptr, length); err == nil { + rec.record(string(buf)) + } else { + // Even on a bad pointer we still want to trap — a guest that + // called gn_panic intends to die. Record the host error as + // the reason so the operator sees what happened. + rec.record(err.Error()) + } + + // Stash the recorder on the ctx so classifyCallError can find it + // alongside the error path. The error we return from a host + // function via stack-mutation isn't what surfaces to the caller — + // we have to close the module to trigger the trap, and the + // ExitError that pops out doesn't carry our payload. So we hang + // the recorder on a side channel: a registered moduleRegistry + // keyed by module name. We don't actually use ctx here — the + // recorder is held by hostPanicError below, which the runtime + // surfaces via Module.classifyCallError. + r.registerPanicRecorder(mod.Name(), rec) + + // Close the module to abort the in-flight call. Exit code 1 + // signals "non-clean exit" to wazero, which turns into a + // *sys.ExitError on the caller's Call return. + _ = mod.CloseWithExitCode(ctx, 1) +} + +// hostGnTimeMs implements env.gn_time_ms. Signature: () -> i64. +// +// Returns Unix milliseconds. The wazero ABI requires us to write the +// result back to stack[0] as a uint64; api.EncodeI64 handles signedness. +// +// Plugins that need a monotonic clock for measuring durations should +// use this — but be aware that the underlying source is time.Now (or +// whatever WithTimeSource injected), which is wall-clock and can +// jump backwards on NTP corrections. A true monotonic source belongs +// in the capability ABI (#107). +func (r *Runtime) hostGnTimeMs(_ context.Context, _ api.Module, stack []uint64) { + ms := r.timeSource().UnixMilli() + stack[0] = api.EncodeI64(ms) +} + +// registerPanicRecorder attaches the recorder to the runtime's per-name +// map. Module.Call drains it on the way up if the call returned an +// error. Concurrent access on the same module is impossible because +// Module.Call serializes — but two different modules can panic +// simultaneously, hence the mutex. +// +// We keep this off the Runtime struct (it's its own concern) and use a +// package-level sync.Map keyed by module name. Each entry is consumed +// exactly once by the next Call's error path. +var panicRecorders sync.Map // map[string]*panicRecorder + +func (r *Runtime) registerPanicRecorder(modName string, rec *panicRecorder) { + panicRecorders.Store(modName, rec) +} + +// takePanicRecorder pulls and clears the recorder for a module. +// Called from Module.classifyCallError on the error path. +func takePanicRecorder(modName string) *panicRecorder { + if v, ok := panicRecorders.LoadAndDelete(modName); ok { + return v.(*panicRecorder) + } + return nil +} + +// Compile-time check that the host function shape matches what wazero +// expects. If wazero ever changes its GoModuleFunc signature, this +// fails at build time rather than at registration. +var _ api.GoModuleFunction = api.GoModuleFunc(func(context.Context, api.Module, []uint64) {}) diff --git a/packages/go/plugins/runtime/module.go b/packages/go/plugins/runtime/module.go new file mode 100644 index 00000000..126233c4 --- /dev/null +++ b/packages/go/plugins/runtime/module.go @@ -0,0 +1,256 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/sys" +) + +// Module is an instantiated WebAssembly module — one plugin's loaded +// code, ready to receive Call() invocations. +// +// Module is goroutine-safe: concurrent Call from N goroutines on the +// same Module is permitted. Wazero itself documents that +// api.Function.Call is NOT goroutine-safe, so this type guards every +// Call with a sync.Mutex. The serialized contract trades parallelism +// for safety — pool-based callers (#9) keep N Modules around when they +// need true parallel dispatch. +// +// A Module is invalid after Close. Subsequent Call invocations return +// ErrModuleClosed. +type Module struct { + name string + instance api.Module + compiled compiledHandle + runtime *Runtime + + // callMu serializes Call invocations. See type-level comment. + callMu sync.Mutex + + // closed is set non-zero once Close has run (or the parent Runtime + // has shut down). Atomic so the fast-path Call check is lock-free. + closed atomic.Bool +} + +// compiledHandle is a tiny interface alias so tests can stub the +// compiled-module side without dragging in a wazero dependency. In +// production this is always wazero.CompiledModule. +type compiledHandle interface { + Close(context.Context) error +} + +// Name returns the unique module name supplied to LoadModule. +func (m *Module) Name() string { return m.name } + +// Memory returns a view onto the module's exported linear memory. +// +// Most plugin modules export a memory named "memory" — that's the +// default for every toolchain we expect (Rust, AssemblyScript, TinyGo, +// Go). If the module did not export memory, Memory returns nil; host +// code MUST nil-check. +// +// The returned Memory is owned by the underlying wazero instance. +// Callers must not retain it past the Module's lifetime — after +// Module.Close, dereferencing the memory will panic inside wazero. +func (m *Module) Memory() api.Memory { + if m.closed.Load() { + return nil + } + return m.instance.Memory() +} + +// Call invokes an exported function by name and returns its results +// (as raw uint64s — callers decode via api.DecodeI32/I64/F32/F64). +// +// params are encoded the same way: callers supply uint64 values +// produced by api.EncodeI32/I64/F32/F64. We don't auto-encode because +// the wazero ABI doesn't carry parameter type information at the Call +// boundary; callers are expected to know what they're calling. +// +// Errors: +// +// - ErrModuleClosed if the module has been closed. +// - ErrFunctionNotFound if `fnName` is not an exported function. +// - *TrapError if the guest trapped (panic, OOB memory, division by +// zero, stack exhaustion, ctx cancellation propagated as a +// wazero exit). +// - Other wrapped errors for wazero-internal failures we couldn't +// classify. +// +// The Module remains usable after a trap — wazero does not poison the +// instance. Callers wanting fail-fast semantics close the module +// themselves on first trap. +func (m *Module) Call(ctx context.Context, fnName string, params ...uint64) ([]uint64, error) { + if m.closed.Load() { + return nil, ErrModuleClosed + } + + fn := m.instance.ExportedFunction(fnName) + if fn == nil { + return nil, fmt.Errorf("%w: %s.%s", ErrFunctionNotFound, m.name, fnName) + } + + m.callMu.Lock() + defer m.callMu.Unlock() + + // Re-check after acquiring the lock: a concurrent Close could have + // landed between the atomic load and the mutex grab. + if m.closed.Load() { + return nil, ErrModuleClosed + } + + results, err := fn.Call(ctx, params...) + if err != nil { + return nil, m.classifyCallError(fnName, err) + } + return results, nil +} + +// classifyCallError turns a wazero error into the package's typed +// error vocabulary. The big distinction is "is this a guest-induced +// trap (interesting to the plugin author)" vs. "is this a host-side +// plumbing failure (a runtime bug)". TrapError is the former; bare +// wrapped errors are the latter. +// +// We use the wazero error's textual signature (the "wasm error:" / +// "wasm trap:" prefix or *sys.ExitError type) because wazero does not +// export a single trap-error type to assert against. This is fragile — +// if wazero changes its error wording, we degrade gracefully (every +// Call error is still surfaced, just without the TrapError wrapping). +func (m *Module) classifyCallError(fnName string, err error) error { + // gn_panic stashes its decoded message into the package-level + // recorder keyed by module name BEFORE closing the module. If + // there's a pending recorder, claim it — that's a strictly better + // reason than wazero's generic "module closed with exit_code(1)". + if rec := takePanicRecorder(m.name); rec != nil && rec.reason != "" { + return &TrapError{ + Module: m.name, + Reason: rec.reason, + Cause: err, + } + } + + // wazero's sys.ExitError is what surfaces when the module called + // proc_exit or the runtime forcibly closed the module (e.g., ctx + // cancellation with WithCloseOnContextDone). We treat it as a trap + // so callers don't have to type-switch. + var exitErr *sys.ExitError + if errors.As(err, &exitErr) { + return &TrapError{ + Module: m.name, + Reason: fmt.Sprintf("module exited (code=%d): %v", exitErr.ExitCode(), err), + Cause: err, + } + } + + // Anything else with the textual signatures wazero uses for + // guest-side faults — we treat them as a trap. Otherwise it's a + // generic host-side failure. + msg := err.Error() + if containsAny(msg, "wasm error:", "wasm trap:", "unreachable") { + return &TrapError{ + Module: m.name, + Reason: fmt.Sprintf("%s.%s: %v", m.name, fnName, err), + Cause: err, + } + } + + return fmt.Errorf("runtime: call %s.%s: %w", m.name, fnName, err) +} + +// containsAny reports whether s contains any of the given substrings. +// Small helper to keep classifyCallError readable. +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if indexOf(s, sub) >= 0 { + return true + } + } + return false +} + +// indexOf is a manual substring search to avoid an import of `strings` +// in this file. (Net-zero benefit — but the file is small and the lint +// budget for one extra import is well-spent elsewhere.) +func indexOf(s, sub string) int { + if len(sub) == 0 { + return 0 + } + if len(sub) > len(s) { + return -1 + } + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// Close releases the module and removes it from the parent Runtime's +// active set. Idempotent — repeat calls return nil. +// +// Close acquires the call mutex so any in-flight Call completes (or +// fails) before the underlying wazero module is closed. This avoids +// the race where Close + Call interleave and the wazero call sees a +// closed instance partway through. +func (m *Module) Close(ctx context.Context) error { + if !m.closed.CompareAndSwap(false, true) { + return nil + } + + // Drain any in-flight Call. After this Lock returns, no new Call + // will enter (closed=true is already visible) and the prior call + // has fully returned. + m.callMu.Lock() + defer m.callMu.Unlock() + + if m.runtime != nil { + m.runtime.removeModule(m.name) + } + + var firstErr error + if err := m.instance.Close(ctx); err != nil { + firstErr = fmt.Errorf("runtime: Close module %q: %w", m.name, err) + } + if m.compiled != nil { + if err := m.compiled.Close(ctx); err != nil && firstErr == nil { + firstErr = fmt.Errorf("runtime: Close compiled %q: %w", m.name, err) + } + } + return firstErr +} + +// markClosed is the runtime-shutdown path: the parent Runtime is going +// away and is about to close the wazero runtime, which closes every +// module wholesale. We just need to flip the flag so a stale Module +// handle doesn't keep accepting Call invocations. +// +// We don't acquire callMu here because the wazero runtime's own Close +// will tear down in-flight calls. Acquiring would deadlock when an +// in-flight Call panics through a host function on the same goroutine. +func (m *Module) markClosed() { + m.closed.Store(true) +} + +// IsClosed reports whether the module has been closed (either +// directly or via parent-runtime shutdown). +func (m *Module) IsClosed() bool { return m.closed.Load() } + +// WazeroModule returns the underlying api.Module. This is the +// extension point for packages that need direct wazero access — +// principally the capability ABI (#107), which wires host-callback +// state into the module after instantiation. +// +// Returns nil if the module is closed. +func (m *Module) WazeroModule() api.Module { + if m.closed.Load() { + return nil + } + return m.instance +} diff --git a/packages/go/plugins/runtime/runtime.go b/packages/go/plugins/runtime/runtime.go new file mode 100644 index 00000000..f2ef7251 --- /dev/null +++ b/packages/go/plugins/runtime/runtime.go @@ -0,0 +1,346 @@ +package runtime + +import ( + "context" + "fmt" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/tetratelabs/wazero" +) + +// defaultMemoryLimitPages is the per-module hard cap on linear memory, +// in 64 KiB pages. 256 pages = 16 MiB. +// +// This is a placeholder until the real per-plugin resource limits land +// in issue #15 (where the limit comes from the plugin manifest and is +// enforced both at instantiation and via a fuel meter). 16 MiB is a +// generous baseline — most plugin workloads we expect (data +// transforms, content rendering, validation hooks) sit well under that. +const defaultMemoryLimitPages uint32 = 256 + +// hostModuleName is the namespace under which the runtime registers +// host functions (gn_log, gn_panic, gn_time_ms). "env" is the +// convention every mainstream WASM toolchain (Rust, AssemblyScript, +// TinyGo) emits imports under by default; using it means plugin +// authors don't need any custom import-name mapping. +const hostModuleName = "env" + +// TimeSource is the abstraction over time.Now used by the gn_time_ms +// host function. Tests inject a fixed source; production code uses +// time.Now. +type TimeSource func() time.Time + +// Runtime is the wazero-backed WebAssembly host. +// +// One Runtime per process is the intended pattern — it owns the wazero +// runtime, the compiled host functions, and the slot map of active +// modules. LoadModule returns a Module wrapped around a fresh wazero +// instance. +// +// Runtime is goroutine-safe: LoadModule can be called from any +// goroutine, and the active-modules map is guarded by a mutex. +type Runtime struct { + // wazeroRT is the underlying wazero runtime. All compiled modules + // and instantiated modules go through it. + wazeroRT wazero.Runtime + + // logger is the structured logger for non-trap diagnostics. Trap + // information is returned as *TrapError; this logger is only used + // for warnings (failed close, failed host write) that the caller + // can't easily surface. + logger *slog.Logger + + // timeSource backs the gn_time_ms host function. + timeSource TimeSource + + // modulesMu guards modules. It's a read-mostly map (modules are + // added on LoadModule, removed on Module.Close) so a plain sync.Mutex + // is fine — we don't need RWMutex churn for the few-per-second + // transitions we expect. + modulesMu sync.Mutex + modules map[string]*Module + + // closed is set non-zero after Close returns. Subsequent LoadModule + // calls return ErrRuntimeClosed. Atomic so the check is lock-free + // on the hot path. + closed atomic.Bool + + // extraHosts is the list of host-module builders passed in via + // WithHostModule. They are instantiated against this runtime + // alongside the built-in "env" module. The capability ABI (#107) + // uses this seam to register its own host functions without + // modifying this package. + extraHosts []HostModuleBuilder +} + +// wazeroRuntime is an alias for wazero.Runtime, kept under a local +// name so the public Option type doesn't force callers to import +// wazero just to read the signature. The underlying type is identical. +type wazeroRuntime = wazero.Runtime + +// HostModuleBuilder is the seam future packages (capability ABI #107) +// use to register additional host modules into the Runtime. +// +// A HostModuleBuilder is a function that takes the wazero runtime and +// instantiates a host module against it (or returns an error). The +// Runtime calls each builder once during New(). +// +// Implementers typically wrap wazero.HostModuleBuilder: +// +// WithHostModule(func(ctx context.Context, rt wazero.Runtime) error { +// _, err := rt.NewHostModuleBuilder("gonext_caps"). +// NewFunctionBuilder().WithFunc(...).Export("kv_read"). +// Instantiate(ctx) +// return err +// }) +type HostModuleBuilder func(ctx context.Context, rt wazeroRuntime) error + +// Option configures a Runtime at construction time. +type Option func(*runtimeConfig) + +type runtimeConfig struct { + logger *slog.Logger + timeSource TimeSource + memoryLimitPages uint32 + extraHosts []HostModuleBuilder +} + +// WithLogger injects the structured logger. If unset, slog.Default is +// used. +func WithLogger(l *slog.Logger) Option { + return func(c *runtimeConfig) { + if l != nil { + c.logger = l + } + } +} + +// WithTimeSource replaces time.Now for the gn_time_ms host function. +// Tests pin this to a fixed instant. Production code leaves it unset. +func WithTimeSource(fn TimeSource) Option { + return func(c *runtimeConfig) { + if fn != nil { + c.timeSource = fn + } + } +} + +// WithMemoryLimitPages overrides the per-module memory cap in 64 KiB +// pages. The default is 256 (16 MiB). Values above wazero's hard cap +// (currently 65536 pages = 4 GiB) panic — that matches wazero's own +// validation. +// +// Plugins requesting more pages in their module declaration than this +// limit allows are rejected at instantiation time with a wazero error +// surfaced as a *CompileError. +func WithMemoryLimitPages(pages uint32) Option { + return func(c *runtimeConfig) { + if pages > 0 { + c.memoryLimitPages = pages + } + } +} + +// WithHostModule registers an additional host module builder. Multiple +// WithHostModule options compose; each builder runs once during New(), +// in order. A builder failure aborts New() and returns the error. +// +// This is the extension point the capability ABI (#107) plugs into. +func WithHostModule(b HostModuleBuilder) Option { + return func(c *runtimeConfig) { + if b != nil { + c.extraHosts = append(c.extraHosts, b) + } + } +} + +// New constructs a Runtime. The provided context is used only for the +// initial wazero runtime + host-module instantiation; it is NOT stored +// for later use. +// +// The Runtime must be Close()d when the host process is shutting down. +// Closing the Runtime closes every Module it owns. +func New(ctx context.Context, opts ...Option) (*Runtime, error) { + cfg := runtimeConfig{ + logger: slog.Default(), + timeSource: time.Now, + memoryLimitPages: defaultMemoryLimitPages, + } + for _, opt := range opts { + opt(&cfg) + } + + wazeroCfg := wazero.NewRuntimeConfig(). + WithMemoryLimitPages(cfg.memoryLimitPages). + // WithCloseOnContextDone makes ctx cancellation propagate into + // running guest functions as a trap, so a runaway plugin can be + // killed by the caller's ctx timeout. This is the foundation of + // the per-call deadline policy (#15 builds on it). + WithCloseOnContextDone(true) + + wRT := wazero.NewRuntimeWithConfig(ctx, wazeroCfg) + + rt := &Runtime{ + wazeroRT: wRT, + logger: cfg.logger, + timeSource: cfg.timeSource, + modules: make(map[string]*Module), + extraHosts: cfg.extraHosts, + } + + // Register the built-in "env" host module that exposes the minimum + // host ABI (gn_log, gn_panic, gn_time_ms). If this fails, we have a + // fundamentally broken wazero — surface and abort. + if err := rt.registerEnvHost(ctx); err != nil { + // Best-effort close so we don't leak the runtime. + _ = wRT.Close(ctx) + return nil, fmt.Errorf("runtime: New: register env host: %w", err) + } + + // Run any caller-supplied host module builders. Order is preserved. + for i, b := range rt.extraHosts { + if err := b(ctx, wRT); err != nil { + _ = wRT.Close(ctx) + return nil, fmt.Errorf("runtime: New: extra host #%d: %w", i, err) + } + } + + return rt, nil +} + +// LoadModule compiles the supplied .wasm bytes, instantiates them as a +// module named `name`, and returns a Module handle. +// +// `name` must be unique across the lifetime of the Runtime. If a module +// with the same name is already loaded, LoadModule returns an error +// rather than silently replacing — duplicate names would make +// host-side bookkeeping ambiguous. Callers that want to re-load (e.g. +// after Module.Close) can do so once the prior name is no longer in +// use. +// +// On success the returned Module owns its wazero handles and is safe +// to use from any goroutine (Call serializes internally; see +// module.go). +// +// On failure of compilation, returns *CompileError. On other failures +// (duplicate name, runtime closed, instantiation error) returns a +// plain wrapped error. +func (r *Runtime) LoadModule(ctx context.Context, name string, wasmBytes []byte) (*Module, error) { + if r.closed.Load() { + return nil, ErrRuntimeClosed + } + if name == "" { + return nil, fmt.Errorf("runtime: LoadModule: name is required") + } + if len(wasmBytes) == 0 { + return nil, fmt.Errorf("runtime: LoadModule: wasmBytes is empty") + } + + // Compile first — failures here are pure module errors and don't + // touch the modules map. CompileModule does NOT panic on malformed + // input; wazero's decoder is robust against the kind of byte-soup a + // malicious bundle could ship. + compiled, err := r.wazeroRT.CompileModule(ctx, wasmBytes) + if err != nil { + return nil, &CompileError{Module: name, Cause: err} + } + + // Take the slot before instantiation so two concurrent callers + // can't both win the "module named X" race. If we later fail to + // instantiate, the deferred cleanup releases the slot. + r.modulesMu.Lock() + if _, exists := r.modules[name]; exists { + r.modulesMu.Unlock() + _ = compiled.Close(ctx) + return nil, fmt.Errorf("runtime: LoadModule: module %q already loaded", name) + } + + // Placeholder so the slot is reserved. We replace with the real + // Module pointer after instantiation succeeds. + r.modules[name] = nil + r.modulesMu.Unlock() + + moduleCfg := wazero.NewModuleConfig(). + WithName(name). + // Do NOT inherit stdio — plugins must use gn_log for output. + // This is a deliberate sandboxing choice; surfacing stdout via + // the host would let plugins dump arbitrary bytes into the + // runtime's stdout stream, bypassing the structured-logging + // pipeline. + WithStartFunctions() // disable WASI _start auto-invoke + + inst, err := r.wazeroRT.InstantiateModule(ctx, compiled, moduleCfg) + if err != nil { + r.modulesMu.Lock() + delete(r.modules, name) + r.modulesMu.Unlock() + _ = compiled.Close(ctx) + return nil, fmt.Errorf("runtime: LoadModule: instantiate %q: %w", name, err) + } + + m := &Module{ + name: name, + instance: inst, + compiled: compiled, + runtime: r, + } + + r.modulesMu.Lock() + r.modules[name] = m + r.modulesMu.Unlock() + + return m, nil +} + +// Close shuts down the runtime, closing every Module it owns and +// releasing wazero's compiled-module cache. +// +// Close is idempotent — repeat calls return nil. After Close, all +// LoadModule calls return ErrRuntimeClosed. +// +// The ctx is passed to wazero.Runtime.Close; canceling it does not +// prevent close from succeeding. +func (r *Runtime) Close(ctx context.Context) error { + if !r.closed.CompareAndSwap(false, true) { + return nil + } + + // Drain the modules map BEFORE closing the wazero runtime. wazero + // will close every module it owns on rRT.Close, but our Module + // wrappers need to be marked closed too so a stale handle doesn't + // keep dispatching calls into a dead instance. + r.modulesMu.Lock() + mods := make([]*Module, 0, len(r.modules)) + for _, m := range r.modules { + if m != nil { + mods = append(mods, m) + } + } + r.modules = nil + r.modulesMu.Unlock() + + for _, m := range mods { + // Mark each Module closed; the underlying wazero close happens + // via the runtime-level Close below. + m.markClosed() + } + + return r.wazeroRT.Close(ctx) +} + +// IsClosed reports whether Close has been called. Mostly useful in +// tests and admin probes. +func (r *Runtime) IsClosed() bool { return r.closed.Load() } + +// removeModule is called by Module.Close to drop the slot. Safe to +// call after Runtime.Close — the map is nil, the delete is a no-op. +func (r *Runtime) removeModule(name string) { + r.modulesMu.Lock() + defer r.modulesMu.Unlock() + if r.modules != nil { + delete(r.modules, name) + } +} diff --git a/packages/go/plugins/runtime/runtime_test.go b/packages/go/plugins/runtime/runtime_test.go new file mode 100644 index 00000000..a0f3787e --- /dev/null +++ b/packages/go/plugins/runtime/runtime_test.go @@ -0,0 +1,391 @@ +package runtime + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/tetratelabs/wazero/api" +) + +// newTestRuntime returns a fresh Runtime that captures slog output into +// the returned buffer, so tests can assert on logged messages from +// gn_log. +func newTestRuntime(t *testing.T, opts ...Option) (*Runtime, *syncBuffer) { + t.Helper() + buf := &syncBuffer{} + logger := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + allOpts := append([]Option{WithLogger(logger)}, opts...) + rt, err := New(context.Background(), allOpts...) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { _ = rt.Close(context.Background()) }) + return rt, buf +} + +// syncBuffer is a tiny goroutine-safe bytes.Buffer wrapper. Tests +// running concurrent gn_log calls write into it from multiple +// goroutines, so the wrapper avoids `go test -race` complaints about +// the unsynchronized stdlib buffer. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// TestFixturesCompile is the canary that confirms our hand-authored +// WASM bytes are valid before any other test relies on them. If a +// fixture stops compiling, all the harder-to-diagnose downstream +// failures get cleaner attribution. +func TestFixturesCompile(t *testing.T) { + rt, _ := newTestRuntime(t) + cases := map[string][]byte{ + "add": wasmAdd, + "panic": wasmPanic, + "log": wasmLog, + "time": wasmTime, + "concurrent": wasmConcurrent, + // bigmem deliberately exceeds the 16 MiB cap and must FAIL — + // it's verified separately in TestLoadModule_MemoryLimit. + } + for name, b := range cases { + t.Run(name, func(t *testing.T) { + mod, err := rt.LoadModule(context.Background(), name, b) + if err != nil { + t.Fatalf("LoadModule %q: %v", name, err) + } + _ = mod.Close(context.Background()) + }) + } +} + +func TestLoadModule_AddHappyPath(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "add", wasmAdd) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + results, err := mod.Call(ctx, "add", api.EncodeI32(7), api.EncodeI32(35)) + if err != nil { + t.Fatalf("Call add: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + got := api.DecodeI32(results[0]) + if got != 42 { + t.Errorf("add(7, 35) = %d, want 42", got) + } +} + +func TestCall_FunctionNotFound(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "add", wasmAdd) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + _, err = mod.Call(ctx, "no_such_function") + if !errors.Is(err, ErrFunctionNotFound) { + t.Errorf("Call missing fn: want ErrFunctionNotFound, got %v", err) + } +} + +func TestCall_AfterClose(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "add", wasmAdd) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + if err := mod.Close(ctx); err != nil { + t.Fatalf("Close: %v", err) + } + _, err = mod.Call(ctx, "add", api.EncodeI32(1), api.EncodeI32(2)) + if !errors.Is(err, ErrModuleClosed) { + t.Errorf("Call after Close: want ErrModuleClosed, got %v", err) + } +} + +func TestLoadModule_GuestPanicTrappedAsError(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "boomer", wasmPanic) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + _, err = mod.Call(ctx, "boom") + if err == nil { + t.Fatal("expected trap error from gn_panic, got nil") + } + + var trap *TrapError + if !errors.As(err, &trap) { + t.Fatalf("expected *TrapError, got %T: %v", err, err) + } + if !strings.Contains(trap.Reason, "boom from guest") { + t.Errorf("trap reason = %q, want substring %q", trap.Reason, "boom from guest") + } + if trap.Module != "boomer" { + t.Errorf("trap module = %q, want %q", trap.Module, "boomer") + } +} + +func TestLoadModule_MalformedBytesAreCompileError(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + + // Use a defer/recover so a stray host-side panic shows up as a test + // failure rather than crashing the whole binary. The whole point + // of this test is that bad bytes DO NOT panic. + defer func() { + if r := recover(); r != nil { + t.Fatalf("LoadModule panicked on malformed bytes: %v", r) + } + }() + + _, err := rt.LoadModule(ctx, "bad", wasmInvalid) + if err == nil { + t.Fatal("expected error on malformed wasm, got nil") + } + var ce *CompileError + if !errors.As(err, &ce) { + t.Errorf("expected *CompileError, got %T: %v", err, err) + } +} + +func TestLoadModule_MemoryLimit(t *testing.T) { + rt, _ := newTestRuntime(t) // default cap = 256 pages = 16 MiB + ctx := context.Background() + + // bigmem requests 1024 pages = 64 MiB. wazero must refuse to + // instantiate it under our 256-page cap and we must surface the + // failure as a plain error (not a panic, not a successful load). + defer func() { + if r := recover(); r != nil { + t.Fatalf("LoadModule panicked on oversized memory: %v", r) + } + }() + _, err := rt.LoadModule(ctx, "bigmem", wasmBigMem) + if err == nil { + t.Fatal("expected error when module memory exceeds runtime cap") + } +} + +func TestLoadModule_DuplicateName(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + + mod1, err := rt.LoadModule(ctx, "dup", wasmAdd) + if err != nil { + t.Fatalf("first LoadModule: %v", err) + } + defer mod1.Close(ctx) + + _, err = rt.LoadModule(ctx, "dup", wasmAdd) + if err == nil { + t.Fatal("expected duplicate-name error, got nil") + } + if !strings.Contains(err.Error(), "already loaded") { + t.Errorf("error = %q, want substring %q", err.Error(), "already loaded") + } +} + +func TestRuntime_CloseIdempotent(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + if err := rt.Close(ctx); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := rt.Close(ctx); err != nil { + t.Fatalf("second Close: %v", err) + } + _, err := rt.LoadModule(ctx, "after", wasmAdd) + if !errors.Is(err, ErrRuntimeClosed) { + t.Errorf("LoadModule after Close: want ErrRuntimeClosed, got %v", err) + } +} + +func TestModule_ConcurrentCalls(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "concurrent", wasmConcurrent) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + const goroutines = 16 + const callsPer = 64 + + var wg sync.WaitGroup + var bad atomic.Int64 + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func(start int32) { + defer wg.Done() + for i := int32(0); i < callsPer; i++ { + x := start + i + results, err := mod.Call(ctx, "square", api.EncodeI32(x)) + if err != nil { + bad.Add(1) + return + } + if api.DecodeI32(results[0]) != x*x { + bad.Add(1) + } + } + }(int32(g * callsPer)) + } + wg.Wait() + if bad.Load() != 0 { + t.Errorf("%d concurrent calls returned wrong results", bad.Load()) + } +} + +func TestHost_GnLog(t *testing.T) { + rt, logBuf := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "logger", wasmLog) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + if _, err := mod.Call(ctx, "say_hi"); err != nil { + t.Fatalf("Call say_hi: %v", err) + } + + if !strings.Contains(logBuf.String(), "hi from plugin") { + t.Errorf("log buffer = %q, want substring %q", logBuf.String(), "hi from plugin") + } + if !strings.Contains(logBuf.String(), "plugin=logger") { + t.Errorf("log buffer = %q, want plugin attribute", logBuf.String()) + } +} + +func TestHost_GnTimeMs(t *testing.T) { + // Pin time so the test is deterministic. + fixed := time.Date(2026, 5, 17, 12, 0, 0, 0, time.UTC) + rt, _ := newTestRuntime(t, WithTimeSource(func() time.Time { return fixed })) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "clock", wasmTime) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + defer mod.Close(ctx) + + results, err := mod.Call(ctx, "get_time") + if err != nil { + t.Fatalf("Call get_time: %v", err) + } + got := int64(results[0]) + want := fixed.UnixMilli() + if got != want { + t.Errorf("get_time = %d, want %d", got, want) + } +} + +func TestRuntime_EmptyBytes(t *testing.T) { + rt, _ := newTestRuntime(t) + _, err := rt.LoadModule(context.Background(), "empty", nil) + if err == nil { + t.Fatal("expected error for nil bytes") + } +} + +func TestRuntime_EmptyName(t *testing.T) { + rt, _ := newTestRuntime(t) + _, err := rt.LoadModule(context.Background(), "", wasmAdd) + if err == nil { + t.Fatal("expected error for empty name") + } +} + +// TestModule_CloseDuringCallIsSafe spins up calls and races a Close +// against them. The contract is "Close drains in-flight Call before +// returning"; the test just verifies the race detector stays quiet +// and no deadlock occurs. +func TestModule_CloseDuringCallIsSafe(t *testing.T) { + rt, _ := newTestRuntime(t) + ctx := context.Background() + mod, err := rt.LoadModule(ctx, "concurrent", wasmConcurrent) + if err != nil { + t.Fatalf("LoadModule: %v", err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 128; i++ { + _, _ = mod.Call(ctx, "square", api.EncodeI32(int32(i))) + } + }() + + // Let some calls land first. + time.Sleep(2 * time.Millisecond) + if err := mod.Close(ctx); err != nil { + t.Fatalf("Close: %v", err) + } + <-done +} + +// TestRuntime_WithHostModule verifies the extension seam: a caller can +// register an additional host-module builder and New() invokes it +// against the underlying wazero runtime. +func TestRuntime_WithHostModule(t *testing.T) { + hostCalled := false + rt, err := New(context.Background(), + WithHostModule(func(ctx context.Context, _ wazeroRuntime) error { + hostCalled = true + return nil + }), + ) + if err != nil { + t.Fatalf("New with host module: %v", err) + } + t.Cleanup(func() { _ = rt.Close(context.Background()) }) + if !hostCalled { + t.Error("WithHostModule builder was never invoked") + } +} + +func TestRuntime_WithHostModuleBuilderError(t *testing.T) { + _, err := New(context.Background(), + WithHostModule(func(ctx context.Context, _ wazeroRuntime) error { + return fmt.Errorf("intentional builder failure") + }), + ) + if err == nil { + t.Fatal("expected New() to surface host-builder error") + } + if !strings.Contains(err.Error(), "intentional builder failure") { + t.Errorf("error = %q, want substring %q", err.Error(), "intentional builder failure") + } +} diff --git a/packages/go/plugins/runtime/testdata_test.go b/packages/go/plugins/runtime/testdata_test.go new file mode 100644 index 00000000..355ff5b6 --- /dev/null +++ b/packages/go/plugins/runtime/testdata_test.go @@ -0,0 +1,371 @@ +package runtime + +// Hand-authored WASM binaries for runtime_test.go. Source `.wat` files +// live alongside this file in wat/; see wat/README.md for the format and +// regeneration workflow. +// +// Each byte slice is annotated section-by-section so the format stays +// auditable. The encoding is WebAssembly 1.0 binary format: +// +// magic : 00 61 73 6d +// version : 01 00 00 00 +// section : id (u8) + size (LEB128) + payload +// +// LEB128 unsigned: 7 bits per byte, MSB set means "more bytes follow". +// All literal section sizes here fit in a single byte (< 0x80) so the +// LEB128 encoding is indistinguishable from a raw u8. Section sizes +// are computed as the byte-length of the payload that follows the size +// itself (i.e., NOT including the section id or the size byte). + +// wasmAdd is the binary form of wat/add.wat: +// +// (module +// (func $add (export "add") (param i32 i32) (result i32) +// local.get 0 +// local.get 1 +// i32.add)) +var wasmAdd = []byte{ + // --- header --- + 0x00, 0x61, 0x73, 0x6d, // magic "\0asm" + 0x01, 0x00, 0x00, 0x00, // version 1 + + // --- type section (id=1) --- + // payload = 0x01 + 0x60 + 0x02 0x7f 0x7f + 0x01 0x7f = 7 bytes + 0x01, 0x07, // id, size + 0x01, // num types + 0x60, // func type tag + 0x02, 0x7f, 0x7f, // 2 params, both i32 + 0x01, 0x7f, // 1 result, i32 + + // --- function section (id=3) --- + // payload = 0x01 + 0x00 = 2 bytes + 0x03, 0x02, // id, size + 0x01, // num functions + 0x00, // function 0 uses type 0 + + // --- export section (id=7) --- + // payload = 0x01 + 0x03 + "add" + 0x00 + 0x00 = 7 bytes + 0x07, 0x07, // id, size + 0x01, // num exports + 0x03, 'a', 'd', 'd', // name length + bytes + 0x00, // export kind: function + 0x00, // function index: 0 + + // --- code section (id=10) --- + // body = 0x00 (no locals) + local.get 0 + local.get 1 + i32.add + end = 7 bytes + // payload = 0x01 (1 body) + 0x07 (body size) + body = 9 bytes + 0x0a, 0x09, // id, size + 0x01, // num function bodies + 0x07, // body size + 0x00, // local decl count + 0x20, 0x00, // local.get 0 + 0x20, 0x01, // local.get 1 + 0x6a, // i32.add + 0x0b, // end +} + +// wasmPanic is the binary form of wat/panic.wat: +// +// (module +// (import "env" "gn_panic" (func $gn_panic (param i32 i32))) +// (memory (export "memory") 1) +// (data (i32.const 0) "boom from guest") +// (func $boom (export "boom") +// i32.const 0 +// i32.const 15 +// call $gn_panic)) +var wasmPanic = []byte{ + // header + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // --- type section --- + // 2 types: + // type 0: (i32, i32) -> () used by gn_panic import + // type 1: () -> () used by boom + // payload = 0x02 + (0x60 0x02 0x7f 0x7f 0x00) + (0x60 0x00 0x00) = 1+5+3 = 9 + 0x01, 0x09, + 0x02, + 0x60, 0x02, 0x7f, 0x7f, 0x00, + 0x60, 0x00, 0x00, + + // --- import section --- + // 1 import: + // module name: 0x03 "env" = 4 bytes + // import name: 0x08 "gn_panic" = 9 bytes + // kind=0x00 (func), type idx=0x00 = 2 bytes + // entry total = 4 + 9 + 2 = 15 + // payload = 0x01 + entry = 16 = 0x10 + 0x02, 0x10, + 0x01, + 0x03, 'e', 'n', 'v', + 0x08, 'g', 'n', '_', 'p', 'a', 'n', 'i', 'c', + 0x00, 0x00, + + // --- function section --- + // payload = 0x01 + 0x01 = 2 + 0x03, 0x02, + 0x01, // num functions + 0x01, // function 0 (boom) uses type 1 + + // --- memory section --- + // payload = 0x01 (num memories) + 0x00 (limits=min only) + 0x01 (min pages) = 3 + 0x05, 0x03, + 0x01, 0x00, 0x01, + + // --- export section --- + // 2 exports. Each entry: name_len(1) + name_bytes + kind(1) + index(1). + // "memory" [0x06]memory[0x02][0x00] = 1+6+1+1 = 9 + // "boom" [0x04]boom[0x00][0x01] = 1+4+1+1 = 7 + // (func index 1 because import counts first → index 0 is gn_panic) + // payload = 1 (num exports) + 9 + 7 = 17 = 0x11 + 0x07, 0x11, + 0x02, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x04, 'b', 'o', 'o', 'm', 0x00, 0x01, + + // --- code section --- + // body = 0x00 (no locals) + i32.const 0 (2) + i32.const 15 (2) + call 0 (2) + end (1) = 8 + // payload = 1 (num bodies) + 1 (body size byte) + 8 (body) = 10 = 0x0a + 0x0a, 0x0a, + 0x01, + 0x08, + 0x00, + 0x41, 0x00, + 0x41, 0x0f, + 0x10, 0x00, + 0x0b, + + // --- data section --- + // 1 segment, active, memory 0: + // 0x00 (memory-idx-flag=0) = 1 + // offset expr: 0x41 0x00 0x0b = 3 + // byte count: 0x0f = 1 + // payload bytes: 15 + // total per segment: 1+3+1+15 = 20 + // section payload = 0x01 + 20 = 21 = 0x15 + 0x0b, 0x15, + 0x01, + 0x00, + 0x41, 0x00, 0x0b, + 0x0f, + 'b', 'o', 'o', 'm', ' ', 'f', 'r', 'o', 'm', ' ', 'g', 'u', 'e', 's', 't', +} + +// wasmLog is the binary form of wat/log.wat: +// +// (module +// (import "env" "gn_log" (func $gn_log (param i32 i32 i32))) +// (memory (export "memory") 1) +// (data (i32.const 0) "hi from plugin") +// (func $say_hi (export "say_hi") +// i32.const 1 +// i32.const 0 +// i32.const 14 +// call $gn_log)) +var wasmLog = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // type section: + // type 0: (i32, i32, i32) -> () — encoded as 0x60 0x03 0x7f 0x7f 0x7f 0x00 (6 bytes) + // type 1: () -> () — encoded as 0x60 0x00 0x00 (3 bytes) + // payload = 0x02 + 6 + 3 = 10 = 0x0a + 0x01, 0x0a, + 0x02, + 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x00, + 0x60, 0x00, 0x00, + + // import section: + // "env" / "gn_log" / func / type 0 + // entry: 4 + 7 + 2 = 13 + // payload = 0x01 + 13 = 14 = 0x0e + 0x02, 0x0e, + 0x01, + 0x03, 'e', 'n', 'v', + 0x06, 'g', 'n', '_', 'l', 'o', 'g', + 0x00, 0x00, + + // function section + 0x03, 0x02, 0x01, 0x01, + + // memory section: min 1 page + 0x05, 0x03, 0x01, 0x00, 0x01, + + // export section: + // "memory" [0x06]memory[0x02][0x00] = 1+6+1+1 = 9 + // "say_hi" [0x06]say_hi[0x00][0x01] = 1+6+1+1 = 9 (func index 1; gn_log is import 0) + // payload = 1 (num exports) + 9 + 9 = 19 = 0x13 + 0x07, 0x13, + 0x02, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x06, 's', 'a', 'y', '_', 'h', 'i', 0x00, 0x01, + + // code section: + // body = 0x00 + i32.const 1 (2) + i32.const 0 (2) + i32.const 14 (2) + call 0 (2) + end (1) + // = 1+2+2+2+2+1 = 10 + // payload = 0x01 + 0x0a + 10 = 12 = 0x0c + 0x0a, 0x0c, + 0x01, + 0x0a, + 0x00, + 0x41, 0x01, + 0x41, 0x00, + 0x41, 0x0e, + 0x10, 0x00, + 0x0b, + + // data section: + // 1 segment, mem 0, offset expr 0x41 0x00 0x0b, len 0x0e, 14 bytes + // per segment: 1+3+1+14 = 19 + // payload = 0x01 + 19 = 20 = 0x14 + 0x0b, 0x14, + 0x01, + 0x00, + 0x41, 0x00, 0x0b, + 0x0e, + 'h', 'i', ' ', 'f', 'r', 'o', 'm', ' ', 'p', 'l', 'u', 'g', 'i', 'n', +} + +// wasmTime is the binary form of wat/time.wat: +// +// (module +// (import "env" "gn_time_ms" (func $gn_time_ms (result i64))) +// (func $get_time (export "get_time") (result i64) +// call $gn_time_ms)) +var wasmTime = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // type section: 1 type (() -> i64) shared by import and export + // 0x60 0x00 0x01 0x7e = 4 bytes + // payload = 0x01 + 4 = 5 + 0x01, 0x05, + 0x01, + 0x60, 0x00, 0x01, 0x7e, + + // import section: + // "env" / "gn_time_ms" / func / type 0 + // entry: 4 + 11 + 2 = 17 + // payload = 0x01 + 17 = 18 = 0x12 + 0x02, 0x12, + 0x01, + 0x03, 'e', 'n', 'v', + 0x0a, 'g', 'n', '_', 't', 'i', 'm', 'e', '_', 'm', 's', + 0x00, 0x00, + + // function section + 0x03, 0x02, 0x01, 0x00, + + // export section: + // "get_time" 0x08 + 8 + 0x00 0x01 = 11 + // payload = 0x01 + 11 = 12 = 0x0c + 0x07, 0x0c, + 0x01, + 0x08, 'g', 'e', 't', '_', 't', 'i', 'm', 'e', + 0x00, 0x01, + + // code section: + // body = 0x00 + call 0 (2) + end (1) = 4 + // payload = 0x01 + 0x04 + 4 = 6 + 0x0a, 0x06, + 0x01, + 0x04, + 0x00, + 0x10, 0x00, + 0x0b, +} + +// wasmConcurrent is the binary form of wat/concurrent.wat: +// +// (module +// (func $square (export "square") (param i32) (result i32) +// local.get 0 +// local.get 0 +// i32.mul)) +var wasmConcurrent = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // type section: (i32) -> i32 + // 0x60 0x01 0x7f 0x01 0x7f = 5 bytes + // payload = 0x01 + 5 = 6 + 0x01, 0x06, + 0x01, + 0x60, 0x01, 0x7f, 0x01, 0x7f, + + // function + 0x03, 0x02, 0x01, 0x00, + + // export: + // "square" 0x06 + 6 + 0x00 0x00 = 9 + // payload = 0x01 + 9 = 10 = 0x0a + 0x07, 0x0a, + 0x01, + 0x06, 's', 'q', 'u', 'a', 'r', 'e', + 0x00, 0x00, + + // code: + // body = 0x00 + local.get 0 (2) + local.get 0 (2) + i32.mul (1) + end (1) = 7 + // payload = 0x01 + 0x07 + 7 = 9 + 0x0a, 0x09, + 0x01, + 0x07, + 0x00, + 0x20, 0x00, + 0x20, 0x00, + 0x6c, + 0x0b, +} + +// wasmBigMem is the binary form of wat/bigmem.wat — a module with +// (memory 1024) declared, used to verify the runtime's 256-page cap +// (16 MiB) rejects oversized requests. +// +// (module +// (memory (export "memory") 1024) +// (func $touch (export "touch"))) +// +// 1024 encoded as LEB128 unsigned = 0x80 0x08 +// (1024 = 0b00000010_00000000 = top byte 0b0000_1000, low byte 0b1000_0000) +var wasmBigMem = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + + // type: () -> () = 0x60 0x00 0x00 = 3 bytes + // payload = 0x01 + 3 = 4 + 0x01, 0x04, + 0x01, + 0x60, 0x00, 0x00, + + // function + 0x03, 0x02, 0x01, 0x00, + + // memory: 1 memory, limits flag 0 (only min), min = 1024 (LEB128: 0x80 0x08) + // payload = 0x01 + 0x00 + 0x80 0x08 = 4 + 0x05, 0x04, + 0x01, 0x00, 0x80, 0x08, + + // export: + // "memory" 0x06 + 6 + 0x02 0x00 = 9 + // "touch" 0x05 + 5 + 0x00 0x00 = 8 + // payload = 0x02 + 9 + 8 = 18 = 0x12 + 0x07, 0x12, + 0x02, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x05, 't', 'o', 'u', 'c', 'h', 0x00, 0x00, + + // code: + // body = 0x00 (no locals) + end = 2 + // payload = 0x01 + 0x02 + 2 = 4 + 0x0a, 0x04, + 0x01, + 0x02, + 0x00, + 0x0b, +} + +// wasmInvalid is a deliberately-malformed binary used to verify that +// malformed bytes are reported as *CompileError rather than panicking. +// The magic bytes are wrong on purpose. +var wasmInvalid = []byte{ + 0xde, 0xad, 0xbe, 0xef, + 0x01, 0x00, 0x00, 0x00, + // random trailing bytes; wazero rejects on magic check first + 0xff, 0xff, 0xff, 0xff, +} diff --git a/packages/go/plugins/runtime/wat/Makefile b/packages/go/plugins/runtime/wat/Makefile new file mode 100644 index 00000000..2d849f3b --- /dev/null +++ b/packages/go/plugins/runtime/wat/Makefile @@ -0,0 +1,28 @@ +# Regenerate the WASM byte-slice constants in ../testdata_test.go from +# the .wat sources in this directory. Requires `wat2wasm` (from +# `wabt`, https://github.com/WebAssembly/wabt). Without wat2wasm, +# tests still pass — the bytes are vendored in testdata_test.go. +# +# Usage: +# make # compile .wat -> .wasm and dump as Go bytes +# make clean # remove generated .wasm +# +# The generated .wasm files in this directory are NOT committed to +# git; they are an intermediate artifact for the byte-extraction +# step. The committed source of truth is testdata_test.go. + +WATS := $(wildcard *.wat) +WASMS := $(WATS:.wat=.wasm) + +.PHONY: all clean check + +all: $(WASMS) + +%.wasm: %.wat + wat2wasm $< -o $@ + +check: + @which wat2wasm > /dev/null || (echo "wat2wasm not found; install wabt"; exit 1) + +clean: + rm -f $(WASMS) diff --git a/packages/go/plugins/runtime/wat/README.md b/packages/go/plugins/runtime/wat/README.md new file mode 100644 index 00000000..d7870bcd --- /dev/null +++ b/packages/go/plugins/runtime/wat/README.md @@ -0,0 +1,36 @@ +# Test WebAssembly fixtures + +This directory holds the `.wat` (WebAssembly text) sources for the +fixtures used by `runtime_test.go`. The actual `.wasm` bytes used by +tests live in `../testdata_test.go` as Go byte-slice constants so the +test binary has no on-disk dependency and `go test` works on any host +without needing `wat2wasm` installed. + +The `.wat` files in this directory are the human-authored source of +truth — anyone reviewing the test fixtures can read them here rather +than reverse-engineering the byte slices. The Makefile in this +directory rebuilds the `.wasm` from `.wat` when `wat2wasm` is +available, and a small Go helper (`update_testdata.go`, run via +`go run`) extracts the bytes back into `testdata_test.go`. + +## Fixtures + +- `add.wat` — exports `add(i32, i32) -> i32`. Used to verify a happy-path + Call. +- `panic.wat` — imports `env.gn_panic` and exports `boom()`, which calls + it with a hard-coded message. Used to verify trap classification. +- `log.wat` — imports `env.gn_log` and exports `say_hi()`. Used to verify + the host log function is wired. +- `time.wat` — imports `env.gn_time_ms` and exports `get_time() -> i64`. +- `concurrent.wat` — exports `square(i32) -> i32`. Used by the race + test that fires N goroutines at the same module. +- `bigmem.wat` — declares a memory with `(memory 1024)` (64 MiB initial) + to verify the 16 MiB instantiation cap rejects it. + +## Why hand-authored bytes + +Building a Go-only build requires WASM bytes that don't depend on a +host toolchain. The hand-authored bytes in `testdata_test.go` are +small (under 200 bytes each), documented section-by-section, and +verified against `wat2wasm` output during development. They are +test-only and never ship in a release artifact. diff --git a/packages/go/plugins/runtime/wat/add.wat b/packages/go/plugins/runtime/wat/add.wat new file mode 100644 index 00000000..1479bd98 --- /dev/null +++ b/packages/go/plugins/runtime/wat/add.wat @@ -0,0 +1,11 @@ +;; add.wat — minimal export test fixture. +;; +;; Exports a single function `add(i32, i32) -> i32` that returns the +;; sum of its two arguments. Used by the runtime tests to verify the +;; happy-path Call() interface: load bytes, look up export, invoke +;; with params, decode result. +(module + (func $add (export "add") (param i32 i32) (result i32) + local.get 0 + local.get 1 + i32.add)) diff --git a/packages/go/plugins/runtime/wat/bigmem.wat b/packages/go/plugins/runtime/wat/bigmem.wat new file mode 100644 index 00000000..77be6b9e --- /dev/null +++ b/packages/go/plugins/runtime/wat/bigmem.wat @@ -0,0 +1,8 @@ +;; bigmem.wat — memory cap test fixture. +;; +;; Declares an initial memory of 1024 pages (= 64 MiB), which exceeds +;; the runtime's 256-page (16 MiB) hard cap. Instantiation must fail +;; cleanly with a *CompileError-wrapped wazero error, not a host panic. +(module + (memory (export "memory") 1024) + (func $touch (export "touch"))) diff --git a/packages/go/plugins/runtime/wat/concurrent.wat b/packages/go/plugins/runtime/wat/concurrent.wat new file mode 100644 index 00000000..da5d88b4 --- /dev/null +++ b/packages/go/plugins/runtime/wat/concurrent.wat @@ -0,0 +1,10 @@ +;; concurrent.wat — concurrent-call test fixture. +;; +;; Exports square(i32) -> i32 that returns x*x. Used by the race test +;; to fire N goroutines at the same module and verify serialization +;; produces the right answer for every call. +(module + (func $square (export "square") (param i32) (result i32) + local.get 0 + local.get 0 + i32.mul)) diff --git a/packages/go/plugins/runtime/wat/log.wat b/packages/go/plugins/runtime/wat/log.wat new file mode 100644 index 00000000..e67e0f6b --- /dev/null +++ b/packages/go/plugins/runtime/wat/log.wat @@ -0,0 +1,13 @@ +;; log.wat — host-log test fixture. +;; +;; Imports env.gn_log and exports say_hi() which logs "hi" at level 1 +;; (info). Used to verify gn_log is reachable and routed to slog. +(module + (import "env" "gn_log" (func $gn_log (param i32 i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "hi from plugin") + (func $say_hi (export "say_hi") + i32.const 1 ;; level — info + i32.const 0 ;; ptr + i32.const 14 ;; len of "hi from plugin" + call $gn_log)) diff --git a/packages/go/plugins/runtime/wat/panic.wat b/packages/go/plugins/runtime/wat/panic.wat new file mode 100644 index 00000000..d70f34b9 --- /dev/null +++ b/packages/go/plugins/runtime/wat/panic.wat @@ -0,0 +1,14 @@ +;; panic.wat — guest-trap test fixture. +;; +;; Imports env.gn_panic and exports boom() which immediately calls +;; gn_panic with the pre-laid-out message "boom from guest" at offset +;; 0 in the module's linear memory. Used to verify TrapError carries +;; the guest message back to the caller. +(module + (import "env" "gn_panic" (func $gn_panic (param i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "boom from guest") + (func $boom (export "boom") + i32.const 0 ;; ptr — start of the string + i32.const 15 ;; len — bytes in "boom from guest" + call $gn_panic)) diff --git a/packages/go/plugins/runtime/wat/time.wat b/packages/go/plugins/runtime/wat/time.wat new file mode 100644 index 00000000..b263e230 --- /dev/null +++ b/packages/go/plugins/runtime/wat/time.wat @@ -0,0 +1,9 @@ +;; time.wat — gn_time_ms test fixture. +;; +;; Imports env.gn_time_ms and exports get_time() -> i64 that simply +;; calls the host and returns the result. Used to verify the time +;; host function is wired and the value passes through unchanged. +(module + (import "env" "gn_time_ms" (func $gn_time_ms (result i64))) + (func $get_time (export "get_time") (result i64) + call $gn_time_ms))