plugin API

plugin

package

API reference for the plugin package.

S
struct

FactoryRegistry

FactoryRegistry stores plugin factories by name.

core/plugin/factory.go:4-6
type FactoryRegistry struct

Methods

Register
Method

Register stores a named factory.

Parameters

name string
factory Factory

Returns

error
func (*FactoryRegistry) Register(name string, factory Factory) error
{
	if _, ok := f.byName[name]; ok {
		return ErrFactoryExists
	}
	f.byName[name] = factory
	return nil
}
Create
Method

Create builds a plugin from a registered factory.

Parameters

name string

Returns

error
func (*FactoryRegistry) Create(name string) (Plugin, error)
{
	factory, ok := f.byName[name]
	if !ok {
		return nil, ErrFactoryNotFound
	}
	return factory(), nil
}

Fields

Name Type Description
byName map[string]Factory
F
function

NewFactoryRegistry

NewFactoryRegistry creates an empty factory registry.

Returns

core/plugin/factory.go:9-11
func NewFactoryRegistry() *FactoryRegistry

{
	return &FactoryRegistry{byName: make(map[string]Factory)}
}
F
function

LoadSo

LoadSo loads a Go plugin (.so) exposing a PluginFactory symbol.

Parameters

path
string

Returns

error
core/plugin/loader_so.go:9-27
func LoadSo(path string) (Factory, error)

{
	p, err := stdplugin.Open(path)
	if err != nil {
		return nil, err
	}

	symbol, err := p.Lookup("PluginFactory")
	if err != nil {
		return nil, err
	}

	if factory, ok := symbol.(func() Plugin); ok {
		return factory, nil
	}
	if factory, ok := symbol.(*func() Plugin); ok {
		return *factory, nil
	}
	return nil, errors.New("plugin: PluginFactory has wrong type")
}
I
interface

Plugin

Plugin defines the lifecycle interface for all plugins.

core/plugin/plugin.go:28-32
type Plugin interface

Methods

Name
Method

Returns

string
func Name(...)
Start
Method

Returns

error
func Start(...)
Stop
Method

Returns

error
func Stop(...)
T
type

Factory

Factory is a function that creates a new plugin instance.

core/plugin/plugin.go:35-35
type Factory func() Plugin
S
struct

Registry

Registry manages plugin registration and lifecycle in a deterministic order.

core/plugin/registry.go:9-15
type Registry struct

Methods

Register
Method

Register adds a plugin to the registry.

Parameters

p Plugin

Returns

error
func (*Registry) Register(p Plugin) error
{
	r.mu.Lock()
	defer r.mu.Unlock()

	name := p.Name()
	if _, ok := r.byName[name]; ok {
		return ErrAlreadyRegistered
	}

	r.byName[name] = p
	r.order = append(r.order, name)
	return nil
}
Unregister
Method

Unregister removes a plugin by name.

Parameters

name string
func (*Registry) Unregister(name string)
{
	r.lifecycleMu.Lock()
	defer r.lifecycleMu.Unlock()

	r.mu.Lock()
	plugin := r.byName[name]
	delete(r.byName, name)
	for i, current := range r.order {
		if current == name {
			r.order = append(r.order[:i], r.order[i+1:]...)
			break
		}
	}
	r.mu.Unlock()
	if r.running[name] {
		delete(r.running, name)
		if plugin != nil {
			_ = plugin.Stop()
		}
	}
}
Get
Method

Get returns a plugin and whether it exists.

Parameters

name string

Returns

bool
func (*Registry) Get(name string) (Plugin, bool)
{
	r.mu.RLock()
	defer r.mu.RUnlock()

	plugin, ok := r.byName[name]
	return plugin, ok
}
Names
Method

Names returns registered plugin names in insertion order.

Returns

[]string
func (*Registry) Names() []string
{
	r.mu.RLock()
	defer r.mu.RUnlock()

	names := make([]string, len(r.order))
	copy(names, r.order)
	return names
}
StartAll
Method

StartAll starts plugins in insertion order and returns any collected errors.

Returns

[]error
func (*Registry) StartAll() []error
{
	r.lifecycleMu.Lock()
	defer r.lifecycleMu.Unlock()

	names := r.Names()
	var errs []error
	var started []string
	for _, name := range names {
		if r.running[name] {
			continue
		}
		p, ok := r.Get(name)
		if !ok {
			continue
		}
		if err := p.Start(); err != nil {
			errs = append(errs, err)
			for i := len(started) - 1; i >= 0; i-- {
				startedPlugin, exists := r.Get(started[i])
				if exists {
					if stopErr := startedPlugin.Stop(); stopErr != nil {
						errs = append(errs, stopErr)
						continue
					}
				}
				delete(r.running, started[i])
			}
			return errs
		}
		r.running[name] = true
		started = append(started, name)
	}
	return errs
}
StopAll
Method

StopAll stops plugins in reverse insertion order and returns any collected errors.

Returns

[]error
func (*Registry) StopAll() []error
{
	r.lifecycleMu.Lock()
	defer r.lifecycleMu.Unlock()

	names := r.Names()
	var errs []error
	for i := len(names) - 1; i >= 0; i-- {
		name := names[i]
		if !r.running[name] {
			continue
		}
		p, ok := r.Get(name)
		if !ok {
			delete(r.running, name)
			continue
		}
		if err := p.Stop(); err != nil {
			errs = append(errs, err)
		} else {
			delete(r.running, name)
		}
	}
	return errs
}
CloseAll
Method

CloseAll stops plugins and closes context-aware resources in reverse insertion order.

Parameters

Returns

[]error
func (*Registry) CloseAll(ctx context.Context) []error
{
	r.lifecycleMu.Lock()
	defer r.lifecycleMu.Unlock()

	names := r.Names()
	var errs []error
	for i := len(names) - 1; i >= 0; i-- {
		name := names[i]
		plugin, ok := r.Get(name)
		if !ok {
			continue
		}
		if r.running[name] {
			if err := plugin.Stop(); err != nil {
				errs = append(errs, err)
			}
			delete(r.running, name)
		}
		if closer, ok := plugin.(interface{ Close(context.Context) error }); ok {
			if err := closer.Close(ctx); err != nil {
				errs = append(errs, err)
			}
		}
	}
	return errs
}

DiscoverFromValues inspects provided values and registers any that implement Plugin.

Parameters

values ...interface{}

Returns

int
func (*Registry) DiscoverFromValues(values ...interface{}) int
{
	if r == nil {
		return 0
	}

	pluginType := reflect.TypeOf((*Plugin)(nil)).Elem()
	count := 0
	for _, value := range values {
		if value == nil {
			continue
		}

		t := reflect.TypeOf(value)
		if t.Implements(pluginType) {
			p, _ := value.(Plugin)
			if p != nil && r.Register(p) == nil {
				count++
			}
			continue
		}

		if t.Kind() == reflect.Ptr && t.Elem().Implements(pluginType) {
			pluginValue, _ := reflect.ValueOf(value).Elem().Interface().(Plugin)
			if pluginValue != nil && r.Register(pluginValue) == nil {
				count++
			}
		}
	}
	return count
}

RegisterWasmDirectory loads and registers every .wasm plugin in a directory. The registry owns successfully registered modules and should close them with CloseAll.

Parameters

directory string
options ...WasmOption

Returns

int
error
func (*Registry) RegisterWasmDirectory(ctx context.Context, directory string, options ...WasmOption) (int, error)
{
	loaded, err := DiscoverWasm(ctx, directory, options...)
	if err != nil {
		return 0, err
	}
	registered := 0
	for index, module := range loaded {
		if err := registry.Register(module); err != nil {
			_ = module.Close(ctx)
			closeWasm(ctx, loaded[index+1:])
			return registered, err
		}
		registered++
	}
	return registered, nil
}

Fields

Name Type Description
mu sync.RWMutex
lifecycleMu sync.Mutex
order []string
byName map[string]Plugin
running map[string]bool
F
function

NewRegistry

NewRegistry creates an empty plugin registry.

Returns

core/plugin/registry.go:18-23
func NewRegistry() *Registry

{
	return &Registry{
		byName:  make(map[string]Plugin),
		running: make(map[string]bool),
	}
}
S
struct

ExecSandbox

ExecSandbox runs a plugin as an external process using a simple JSON-over-stdio protocol.

core/plugin/sandbox.go:14-19
type ExecSandbox struct

Methods

Start
Method

Start launches the external process and waits for a ready signal.

Parameters

Returns

error
func (*ExecSandbox) Start(ctx context.Context) error
{
	e.mu.Lock()
	defer e.mu.Unlock()

	if e.cmd.Process != nil {
		return errors.New("plugin: sandbox already started")
	}

	in, err := e.cmd.StdinPipe()
	if err != nil {
		return err
	}
	out, err := e.cmd.StdoutPipe()
	if err != nil {
		return err
	}

	e.in = in
	e.out = out
	if err := e.cmd.Start(); err != nil {
		return err
	}

	decoder := json.NewDecoder(io.LimitReader(e.out, maxReadyMessageSize+1))
	ready := make(chan error, 1)
	go func() {
		var msg map[string]any
		if err := decoder.Decode(&msg); err != nil {
			ready <- err
			return
		}
		if ok, _ := msg["ready"].(bool); ok {
			ready <- nil
			return
		}
		ready <- errors.New("plugin: unexpected ready message")
	}()

	startCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	select {
	case err := <-ready:
		if err == nil {
			return nil
		}
		return errors.Join(err, e.killAndWait())
	case <-startCtx.Done():
		return errors.Join(startCtx.Err(), e.killAndWait())
	}
}
Stop
Method

Stop sends a stop command and waits for the external process to exit.

Returns

error
func (*ExecSandbox) Stop() error
{
	ctx, cancel := context.WithTimeout(context.Background(), defaultStopTimeout)
	defer cancel()
	return e.StopContext(ctx)
}
StopContext
Method

StopContext sends a stop command and enforces the supplied shutdown deadline.

Parameters

Returns

error
func (*ExecSandbox) StopContext(ctx context.Context) error
{
	e.mu.Lock()
	defer e.mu.Unlock()

	if e.cmd.Process == nil || e.cmd.ProcessState != nil {
		return errors.New("plugin: sandbox not started")
	}

	done := make(chan error, 1)
	go func() {
		if err := json.NewEncoder(e.in).Encode(map[string]any{"cmd": "stop"}); err != nil {
			done <- errors.Join(err, e.killAndWait())
			return
		}
		done <- e.cmd.Wait()
	}()

	select {
	case err := <-done:
		e.closePipes()
		return err
	case <-ctx.Done():
		_ = e.cmd.Process.Kill()
		_ = e.in.Close()
		err := <-done
		e.closePipes()
		return errors.Join(ctx.Err(), err)
	}
}
killAndWait
Method

Returns

error
func (*ExecSandbox) killAndWait() error
{
	if e.cmd.Process == nil || e.cmd.ProcessState != nil {
		e.closePipes()
		return nil
	}
	killErr := e.cmd.Process.Kill()
	waitErr := e.cmd.Wait()
	e.closePipes()
	return errors.Join(killErr, waitErr)
}
closePipes
Method
func (*ExecSandbox) closePipes()
{
	if e.in != nil {
		_ = e.in.Close()
	}
	if e.out != nil {
		_ = e.out.Close()
	}
}

Fields

Name Type Description
cmd *exec.Cmd
in io.WriteCloser
out io.ReadCloser
mu sync.Mutex
F
function

NewExecSandbox

NewExecSandbox creates a sandbox around the provided binary path and arguments.

Parameters

path
string
args
...string

Returns

core/plugin/sandbox.go:27-29
func NewExecSandbox(path string, args ...string) *ExecSandbox

{
	return &ExecSandbox{cmd: exec.Command(path, args...)}
}
F
function

TestExecSandboxCleansUpAfterStartTimeout

Parameters

core/plugin/sandbox_test.go:10-21
func TestExecSandboxCleansUpAfterStartTimeout(t *testing.T)

{
	sandbox := NewExecSandbox("/bin/sh", "-c", "sleep 30")
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()

	if err := sandbox.Start(ctx); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("Start() error = %v, want deadline exceeded", err)
	}
	if sandbox.cmd.ProcessState == nil {
		t.Fatal("timed out sandbox process was not reaped")
	}
}
F
function

TestExecSandboxStopContextKillsUnresponsiveProcess

Parameters

core/plugin/sandbox_test.go:23-41
func TestExecSandboxStopContextKillsUnresponsiveProcess(t *testing.T)

{
	sandbox := NewExecSandbox(
		"/bin/sh",
		"-c",
		`printf '{"ready":true}\n'; sleep 30`,
	)
	if err := sandbox.Start(context.Background()); err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	if err := sandbox.StopContext(ctx); !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("StopContext() error = %v, want deadline exceeded", err)
	}
	if sandbox.cmd.ProcessState == nil {
		t.Fatal("unresponsive sandbox process was not reaped")
	}
}
F
function

TestExecSandboxReapsProcessWhenStopWriteFails

Parameters

core/plugin/sandbox_test.go:43-60
func TestExecSandboxReapsProcessWhenStopWriteFails(t *testing.T)

{
	sandbox := NewExecSandbox(
		"/bin/sh",
		"-c",
		`printf '{"ready":true}\n'; exec 0<&-; sleep 30`,
	)
	if err := sandbox.Start(context.Background()); err != nil {
		t.Fatal(err)
	}
	time.Sleep(20 * time.Millisecond)

	if err := sandbox.Stop(); err == nil {
		t.Fatal("Stop() succeeded after the child closed stdin")
	}
	if sandbox.cmd.ProcessState == nil {
		t.Fatal("sandbox process was not reaped after stop write failure")
	}
}
T
type

WasmHostStatus

WasmHostStatus is returned to a guest after a host capability call.

core/plugin/wasm.go:50-50
type WasmHostStatus uint32
I
interface

Capability

Capability handles one operation requested by a WebAssembly plugin.

core/plugin/wasm.go:66-68
type Capability interface

Methods

Call
Method

Parameters

string
[]byte

Returns

[]byte
error
func Call(...)
T
type

CapabilityFunc

CapabilityFunc adapts a function into a Capability.

core/plugin/wasm.go:71-71
type CapabilityFunc func(context.Context, string, []byte) ([]byte, error)
S
struct

WasmMetadata

WasmMetadata describes a module before its lifecycle starts.

core/plugin/wasm.go:82-89
type WasmMetadata struct

Fields

Name Type Description
Name string json:"name"
Version string json:"version"
Description string json:"description,omitempty"
Methods []string json:"methods,omitempty"
Capabilities []string json:"capabilities,omitempty"
Properties map[string]string json:"properties,omitempty"
S
struct
Implements: Plugin

WasmPlugin

WasmPlugin is an in-process plugin isolated by a WebAssembly runtime.

core/plugin/wasm.go:92-105
type WasmPlugin struct

Methods

Name
Method

Name returns the stable plugin name declared by the guest.

Returns

string
func (*WasmPlugin) Name() string
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	return plugin.metadata.Name
}
Metadata
Method

Metadata returns a copy of the guest metadata.

Returns

func (*WasmPlugin) Metadata() WasmMetadata
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	metadata := plugin.metadata
	metadata.Methods = slices.Clone(metadata.Methods)
	metadata.Capabilities = slices.Clone(metadata.Capabilities)
	metadata.Properties = cloneStrings(metadata.Properties)
	return metadata
}
Start
Method

Start starts the guest with the configured default deadline.

Returns

error
func (*WasmPlugin) Start() error
{
	return plugin.StartContext(context.Background())
}
StartContext
Method

StartContext starts the guest lifecycle.

Parameters

Returns

error
func (*WasmPlugin) StartContext(ctx context.Context) error
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	if err := plugin.checkOpen(); err != nil {
		return err
	}
	if plugin.started {
		return nil
	}
	if err := plugin.callStatus(ctx, wasmExportStart); err != nil {
		return fmt.Errorf("%w: start: %v", ErrWasmCallFailed, err)
	}
	plugin.started = true
	return nil
}
Stop
Method

Stop stops the guest with the configured default deadline.

Returns

error
func (*WasmPlugin) Stop() error
{
	return plugin.StopContext(context.Background())
}
StopContext
Method

StopContext stops the guest lifecycle.

Parameters

Returns

error
func (*WasmPlugin) StopContext(ctx context.Context) error
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	if err := plugin.checkOpen(); err != nil {
		return err
	}
	if !plugin.started {
		return nil
	}
	if err := plugin.callStatus(ctx, wasmExportStop); err != nil {
		return fmt.Errorf("%w: stop: %v", ErrWasmCallFailed, err)
	}
	plugin.started = false
	return nil
}
Call
Method

Call invokes a declared guest method with an opaque byte payload.

Parameters

method string
input []byte

Returns

[]byte
error
func (*WasmPlugin) Call(ctx context.Context, method string, input []byte) ([]byte, error)
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	if err := plugin.checkOpen(); err != nil {
		return nil, err
	}
	if !plugin.started {
		return nil, ErrWasmNotStarted
	}
	if _, ok := plugin.methods[method]; !ok {
		return nil, fmt.Errorf("%w: method %q is not declared", ErrWasmCallFailed, method)
	}
	if len(input) > int(plugin.config.payloadLimit) {
		return nil, fmt.Errorf("%w: request exceeds %d bytes", ErrWasmCallFailed, plugin.config.payloadLimit)
	}

	methodPointer, err := plugin.writeGuest(ctx, []byte(method))
	if err != nil {
		return nil, err
	}
	defer plugin.freeGuest(methodPointer, uint32(len(method)))
	inputPointer, err := plugin.writeGuest(ctx, input)
	if err != nil {
		return nil, err
	}
	defer plugin.freeGuest(inputPointer, uint32(len(input)))

	callCtx, cancel := plugin.context(ctx)
	defer cancel()
	result, err := plugin.module.ExportedFunction(wasmExportCall).Call(
		callCtx,
		uint64(methodPointer),
		uint64(len(method)),
		uint64(inputPointer),
		uint64(len(input)),
	)
	if err != nil {
		return nil, plugin.runtimeError(err)
	}
	if result[0] == wasmErrorResult {
		return nil, fmt.Errorf("%w: %s", ErrWasmCallFailed, plugin.lastGuestError(callCtx))
	}
	outputPointer, outputLength := unpackWasmBuffer(result[0])
	if outputLength > plugin.config.payloadLimit {
		return nil, fmt.Errorf("%w: response exceeds %d bytes", ErrWasmCallFailed, plugin.config.payloadLimit)
	}
	output, err := plugin.readGuest(outputPointer, outputLength)
	if err != nil {
		return nil, err
	}
	plugin.freeGuest(outputPointer, outputLength)
	return output, nil
}
CallJSON
Method

CallJSON marshals input, invokes a method, and unmarshals its response.

Parameters

method string
input any
output any

Returns

error
func (*WasmPlugin) CallJSON(ctx context.Context, method string, input, output any) error
{
	request, err := json.Marshal(input)
	if err != nil {
		return fmt.Errorf("plugin: encode WebAssembly request: %w", err)
	}
	response, err := plugin.Call(ctx, method, request)
	if err != nil {
		return err
	}
	if output == nil || len(response) == 0 {
		return nil
	}
	if err := json.Unmarshal(response, output); err != nil {
		return fmt.Errorf("plugin: decode WebAssembly response: %w", err)
	}
	return nil
}
Close
Method

Close stops the plugin if needed and releases its runtime.

Parameters

Returns

error
func (*WasmPlugin) Close(ctx context.Context) error
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	if plugin.closed {
		return nil
	}
	if ctx == nil {
		ctx = context.Background()
	}
	var stopErr error
	if plugin.started && plugin.module != nil && !plugin.module.IsClosed() {
		if err := plugin.callStatus(ctx, wasmExportStop); err != nil {
			stopErr = fmt.Errorf("%w: stop: %v", ErrWasmCallFailed, err)
		}
	}
	plugin.started = false
	plugin.closed = true
	return errors.Join(stopErr, plugin.runtime.Close(ctx))
}
Closed
Method

Closed reports whether the plugin runtime can accept more calls.

Returns

bool
func (*WasmPlugin) Closed() bool
{
	plugin.mu.Lock()
	defer plugin.mu.Unlock()
	return plugin.closed || plugin.module == nil || plugin.module.IsClosed()
}
moduleConfig
Method
func (*WasmPlugin) moduleConfig() wazero.ModuleConfig
{
	config := wazero.NewModuleConfig().WithName("").WithStartFunctions()
	if len(plugin.config.wasiArgs) > 0 {
		config = config.WithArgs(plugin.config.wasiArgs...)
	}
	for key, value := range plugin.config.wasiEnv {
		config = config.WithEnv(key, value)
	}
	if len(plugin.config.wasiFS) > 0 {
		filesystems := wazero.NewFSConfig()
		for _, mount := range plugin.config.wasiFS {
			filesystems = filesystems.WithFSMount(mount.fs, mount.guestPath)
		}
		config = config.WithFSConfig(filesystems)
	}
	if plugin.config.stdin != nil {
		config = config.WithStdin(plugin.config.stdin)
	}
	if plugin.config.stdout != nil {
		config = config.WithStdout(plugin.config.stdout)
	}
	if plugin.config.stderr != nil {
		config = config.WithStderr(plugin.config.stderr)
	}
	if plugin.config.random != nil {
		config = config.WithRandSource(plugin.config.random)
	}
	if plugin.config.systemClock {
		config = config.WithSysWalltime().WithSysNanotime().WithSysNanosleep()
	}
	return config
}
loadMetadata
Method

Parameters

Returns

error
func (*WasmPlugin) loadMetadata(ctx context.Context) error
{
	callCtx, cancel := plugin.context(ctx)
	defer cancel()
	version, err := plugin.module.ExportedFunction(wasmExportVersion).Call(callCtx)
	if err != nil {
		return plugin.runtimeError(err)
	}
	major, minor := uint32(version[0]>>32), uint32(version[0])
	if major != WasmABIMajor || minor > WasmABIMinor {
		return fmt.Errorf("%w: guest %d.%d, host %d.%d", ErrWasmABIMismatch, major, minor, WasmABIMajor, WasmABIMinor)
	}

	result, err := plugin.module.ExportedFunction(wasmExportMetadata).Call(callCtx)
	if err != nil {
		return plugin.runtimeError(err)
	}
	pointer, length := unpackWasmBuffer(result[0])
	if length == 0 || length > maxWasmMetadataSize {
		return fmt.Errorf("%w: metadata must contain 1 to %d bytes", ErrWasmInvalidModule, maxWasmMetadataSize)
	}
	data, err := plugin.readGuest(pointer, length)
	if err != nil {
		return err
	}
	metadata, err := decodeWasmMetadata(data)
	if err != nil {
		return err
	}
	plugin.metadata = metadata
	if err := plugin.validateMetadata(); err != nil {
		return err
	}
	return nil
}

Returns

error
func (*WasmPlugin) validateMetadata() error
{
	if err := validateWasmName(plugin.metadata.Name); err != nil {
		return fmt.Errorf("%w: plugin name: %v", ErrWasmInvalidModule, err)
	}
	if strings.TrimSpace(plugin.metadata.Version) == "" || len(plugin.metadata.Version) > maxWasmNameSize {
		return fmt.Errorf("%w: plugin version must contain 1 to %d bytes", ErrWasmInvalidModule, maxWasmNameSize)
	}
	if len(plugin.metadata.Description) > maxWasmDescription {
		return fmt.Errorf("%w: plugin description exceeds %d bytes", ErrWasmInvalidModule, maxWasmDescription)
	}
	for _, method := range plugin.metadata.Methods {
		if err := validateWasmName(method); err != nil {
			return fmt.Errorf("%w: method %q: %v", ErrWasmInvalidModule, method, err)
		}
		if _, exists := plugin.methods[method]; exists {
			return fmt.Errorf("%w: duplicate method %q", ErrWasmInvalidModule, method)
		}
		plugin.methods[method] = struct{}{}
	}
	for _, capability := range plugin.metadata.Capabilities {
		if err := validateWasmName(capability); err != nil {
			return fmt.Errorf("%w: capability %q: %v", ErrWasmInvalidModule, capability, err)
		}
		if _, exists := plugin.declared[capability]; exists {
			return fmt.Errorf("%w: duplicate capability %q", ErrWasmInvalidModule, capability)
		}
		plugin.declared[capability] = struct{}{}
		if _, granted := plugin.config.capabilities[capability]; !granted {
			return fmt.Errorf("%w: %s", ErrWasmCapabilityDenied, capability)
		}
	}
	return nil
}
callStatus
Method

Parameters

name string

Returns

error
func (*WasmPlugin) callStatus(ctx context.Context, name string) error
{
	callCtx, cancel := plugin.context(ctx)
	defer cancel()
	result, err := plugin.module.ExportedFunction(name).Call(callCtx)
	if err != nil {
		return plugin.runtimeError(err)
	}
	if result[0] != 0 {
		return errors.New(plugin.lastGuestError(callCtx))
	}
	return nil
}
writeGuest
Method

Parameters

data []byte

Returns

uint32
error
func (*WasmPlugin) writeGuest(ctx context.Context, data []byte) (uint32, error)
{
	if len(data) == 0 {
		return 0, nil
	}
	callCtx, cancel := plugin.context(ctx)
	defer cancel()
	result, err := plugin.module.ExportedFunction(wasmExportAllocate).Call(callCtx, uint64(len(data)))
	if err != nil {
		return 0, plugin.runtimeError(err)
	}
	pointer := uint32(result[0])
	if pointer == 0 || !plugin.module.Memory().Write(pointer, data) {
		return 0, fmt.Errorf("%w: guest allocation is outside memory", ErrWasmInvalidModule)
	}
	return pointer, nil
}
freeGuest
Method

Parameters

pointer uint32
length uint32
func (*WasmPlugin) freeGuest(pointer, length uint32)
{
	if pointer == 0 || plugin.module == nil || plugin.module.IsClosed() {
		return
	}
	ctx, cancel := plugin.context(context.Background())
	defer cancel()
	_, _ = plugin.module.ExportedFunction(wasmExportFree).Call(ctx, uint64(pointer), uint64(length))
}
readGuest
Method

Parameters

pointer uint32
length uint32

Returns

[]byte
error
func (*WasmPlugin) readGuest(pointer, length uint32) ([]byte, error)
{
	if length == 0 {
		return nil, nil
	}
	data, ok := plugin.module.Memory().Read(pointer, length)
	if !ok {
		return nil, fmt.Errorf("%w: guest buffer is outside memory", ErrWasmInvalidModule)
	}
	return bytes.Clone(data), nil
}

Parameters

Returns

string
func (*WasmPlugin) lastGuestError(ctx context.Context) string
{
	result, err := plugin.module.ExportedFunction(wasmExportLastError).Call(ctx)
	if err != nil || len(result) == 0 {
		return "guest did not provide an error"
	}
	pointer, length := unpackWasmBuffer(result[0])
	if length == 0 || length > plugin.config.payloadLimit {
		return "guest returned an invalid error"
	}
	message, err := plugin.readGuest(pointer, length)
	if err != nil {
		return "guest returned an invalid error"
	}
	return string(message)
}
checkOpen
Method

Returns

error
func (*WasmPlugin) checkOpen() error
{
	if plugin.closed || plugin.module == nil || plugin.module.IsClosed() {
		return ErrWasmClosed
	}
	return nil
}
runtimeError
Method

Parameters

err error

Returns

error
func (*WasmPlugin) runtimeError(err error) error
{
	if plugin.module == nil || plugin.module.IsClosed() {
		return errors.Join(ErrWasmClosed, err)
	}
	return fmt.Errorf("%w: %v", ErrWasmCallFailed, err)
}
context
Method
func (*WasmPlugin) context(ctx context.Context) (context.Context, context.CancelFunc)
{
	if ctx == nil {
		ctx = context.Background()
	}
	if plugin.config.callTimeout <= 0 {
		return context.WithCancel(ctx)
	}
	if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) <= plugin.config.callTimeout {
		return context.WithCancel(ctx)
	}
	return context.WithTimeout(ctx, plugin.config.callTimeout)
}

Returns

error
func (*WasmPlugin) validateContract() error
{
	for _, imported := range plugin.compiled.ImportedFunctions() {
		module, name, ok := imported.Import()
		if !ok {
			continue
		}
		if module == wasmHostModule {
			if _, allowed := wasmHostFunctions[name]; allowed {
				continue
			}
			return fmt.Errorf("%w: unknown host import %s.%s", ErrWasmInvalidModule, module, name)
		}
		if plugin.config.wasi && module == wasiModuleName {
			continue
		}
		return fmt.Errorf("%w: import %s.%s is not allowed", ErrWasmInvalidModule, module, name)
	}
	if len(plugin.compiled.ImportedMemories()) > 0 {
		return fmt.Errorf("%w: imported memory is not allowed", ErrWasmInvalidModule)
	}
	if _, ok := plugin.compiled.ExportedMemories()["memory"]; !ok {
		return fmt.Errorf("%w: memory export is missing", ErrWasmInvalidModule)
	}
	for _, signature := range wasmFunctions {
		function, ok := plugin.compiled.ExportedFunctions()[signature.name]
		if !ok {
			return fmt.Errorf("%w: export %s is missing", ErrWasmInvalidModule, signature.name)
		}
		if !equalValueTypes(function.ParamTypes(), signature.parameters) || !equalValueTypes(function.ResultTypes(), signature.results) {
			return fmt.Errorf("%w: export %s has the wrong signature", ErrWasmInvalidModule, signature.name)
		}
	}
	return nil
}

Parameters

Returns

error
func (*WasmPlugin) instantiateHost(ctx context.Context) error
{
	builder := plugin.runtime.NewHostModuleBuilder(wasmHostModule)
	builder.NewFunctionBuilder().WithFunc(plugin.hostCall).Export(wasmImportHostCall)
	builder.NewFunctionBuilder().WithFunc(plugin.hostResponseLen).Export(wasmImportResultLen)
	builder.NewFunctionBuilder().WithFunc(plugin.hostResponseRead).Export(wasmImportResultRead)
	builder.NewFunctionBuilder().WithFunc(plugin.hostErrorLen).Export(wasmImportErrorLen)
	builder.NewFunctionBuilder().WithFunc(plugin.hostErrorRead).Export(wasmImportErrorRead)
	if _, err := builder.Instantiate(ctx); err != nil {
		return fmt.Errorf("%w: instantiate host ABI: %v", ErrWasmInvalidModule, err)
	}
	return nil
}
hostCall
Method

Parameters

module api.Module
capabilityPointer uint32
capabilityLength uint32
operationPointer uint32
operationLength uint32
inputPointer uint32
inputLength uint32

Returns

uint32
func (*WasmPlugin) hostCall(ctx context.Context, module api.Module, capabilityPointer, capabilityLength, operationPointer, operationLength, inputPointer, inputLength uint32) uint32
{
	plugin.hostResponse = nil
	plugin.hostError = nil
	if capabilityLength > maxWasmNameSize || operationLength > maxWasmNameSize || inputLength > plugin.config.payloadLimit {
		plugin.setHostError("request exceeds its size limit")
		return uint32(WasmHostPayloadTooLarge)
	}
	capability, ok := readModuleBytes(module, capabilityPointer, capabilityLength)
	if !ok {
		plugin.setHostError("capability is outside guest memory")
		return uint32(WasmHostInvalidRequest)
	}
	operation, ok := readModuleBytes(module, operationPointer, operationLength)
	if !ok {
		plugin.setHostError("operation is outside guest memory")
		return uint32(WasmHostInvalidRequest)
	}
	input, ok := readModuleBytes(module, inputPointer, inputLength)
	if !ok {
		plugin.setHostError("input is outside guest memory")
		return uint32(WasmHostInvalidRequest)
	}
	capabilityName := string(capability)
	if validateWasmName(capabilityName) != nil || validateWasmName(string(operation)) != nil {
		plugin.setHostError("capability or operation name is invalid")
		return uint32(WasmHostInvalidRequest)
	}
	if _, declared := plugin.declared[capabilityName]; !declared {
		plugin.setHostError("capability was not declared by the plugin")
		return uint32(WasmHostDenied)
	}
	handler, granted := plugin.config.capabilities[capabilityName]
	if !granted {
		plugin.setHostError("capability was not granted by the host")
		return uint32(WasmHostDenied)
	}
	response, err := handler.Call(ctx, string(operation), input)
	if err != nil {
		plugin.setHostError(err.Error())
		return uint32(WasmHostHandlerError)
	}
	if len(response) > int(plugin.config.payloadLimit) {
		plugin.setHostError("response exceeds the payload limit")
		return uint32(WasmHostPayloadTooLarge)
	}
	plugin.hostResponse = append(plugin.hostResponse[:0], response...)
	return uint32(WasmHostOK)
}

Returns

uint32
func (*WasmPlugin) hostResponseLen() uint32
{
	return uint32(len(plugin.hostResponse))
}

Parameters

module api.Module
pointer uint32
capacity uint32

Returns

int32
func (*WasmPlugin) hostResponseRead(_ context.Context, module api.Module, pointer, capacity uint32) int32
{
	return writeModuleBytes(module, pointer, capacity, plugin.hostResponse)
}
hostErrorLen
Method

Returns

uint32
func (*WasmPlugin) hostErrorLen() uint32
{
	return uint32(len(plugin.hostError))
}
hostErrorRead
Method

Parameters

module api.Module
pointer uint32
capacity uint32

Returns

int32
func (*WasmPlugin) hostErrorRead(_ context.Context, module api.Module, pointer, capacity uint32) int32
{
	return writeModuleBytes(module, pointer, capacity, plugin.hostError)
}
setHostError
Method

Parameters

message string
func (*WasmPlugin) setHostError(message string)
{
	limit := int(plugin.config.payloadLimit)
	if len(message) > limit {
		message = message[:limit]
	}
	plugin.hostError = append(plugin.hostError[:0], message...)
}

Fields

Name Type Description
mu sync.Mutex
runtime wazero.Runtime
compiled wazero.CompiledModule
module api.Module
config wasmConfig
metadata WasmMetadata
methods map[string]struct{}
declared map[string]struct{}
hostResponse []byte
hostError []byte
started bool
closed bool
F
function

LoadWasmFile

LoadWasmFile loads and validates a WebAssembly plugin from disk.

Parameters

path
string
options
...WasmOption

Returns

error
core/plugin/wasm.go:108-118
func LoadWasmFile(ctx context.Context, path string, options ...WasmOption) (*WasmPlugin, error)

{
	binary, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("plugin: read WebAssembly module: %w", err)
	}
	loaded, err := LoadWasm(ctx, binary, options...)
	if err != nil {
		return nil, fmt.Errorf("plugin: load %s: %w", path, err)
	}
	return loaded, nil
}
F
function

LoadWasm

LoadWasm loads and validates a WebAssembly plugin binary.

Parameters

binary
[]byte
options
...WasmOption

Returns

error
core/plugin/wasm.go:121-184
func LoadWasm(ctx context.Context, binary []byte, options ...WasmOption) (*WasmPlugin, error)

{
	config := defaultWasmConfig()
	for _, option := range options {
		if option != nil {
			option(&config)
		}
	}
	if err := config.validate(); err != nil {
		return nil, err
	}
	if len(binary) == 0 {
		return nil, fmt.Errorf("%w: empty binary", ErrWasmInvalidModule)
	}
	if ctx == nil {
		ctx = context.Background()
	}

	runtimeConfig := wazero.NewRuntimeConfig().
		WithMemoryLimitPages(config.memoryPages()).
		WithCloseOnContextDone(true)
	if config.cache != nil {
		runtimeConfig = runtimeConfig.WithCompilationCache(config.cache)
	}

	loaded := &WasmPlugin{
		config:   config,
		methods:  make(map[string]struct{}),
		declared: make(map[string]struct{}),
	}
	loaded.runtime = wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
	if err := loaded.instantiateHost(ctx); err != nil {
		_ = loaded.runtime.Close(ctx)
		return nil, err
	}
	if config.wasi {
		if _, err := wasi_snapshot_preview1.Instantiate(ctx, loaded.runtime); err != nil {
			_ = loaded.runtime.Close(ctx)
			return nil, fmt.Errorf("%w: instantiate WASI: %v", ErrWasmInvalidModule, err)
		}
	}

	compiled, err := loaded.runtime.CompileModule(ctx, binary)
	if err != nil {
		_ = loaded.runtime.Close(ctx)
		return nil, fmt.Errorf("%w: compile: %v", ErrWasmInvalidModule, err)
	}
	loaded.compiled = compiled
	if err := loaded.validateContract(); err != nil {
		_ = loaded.runtime.Close(ctx)
		return nil, err
	}

	module, err := loaded.runtime.InstantiateModule(ctx, compiled, loaded.moduleConfig())
	if err != nil {
		_ = loaded.runtime.Close(ctx)
		return nil, fmt.Errorf("%w: instantiate: %v", ErrWasmInvalidModule, err)
	}
	loaded.module = module
	if err := loaded.loadMetadata(ctx); err != nil {
		_ = loaded.runtime.Close(ctx)
		return nil, err
	}
	return loaded, nil
}
F
function

decodeWasmMetadata

Parameters

data
[]byte

Returns

error
core/plugin/wasm.go:418-428
func decodeWasmMetadata(data []byte) (WasmMetadata, error)

{
	var metadata WasmMetadata
	decoder := json.NewDecoder(bytes.NewReader(data))
	if err := decoder.Decode(&metadata); err != nil {
		return WasmMetadata{}, fmt.Errorf("%w: decode metadata: %v", ErrWasmInvalidModule, err)
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return WasmMetadata{}, fmt.Errorf("%w: metadata contains trailing data", ErrWasmInvalidModule)
	}
	return metadata, nil
}
F
function

unpackWasmBuffer

Parameters

value
uint64

Returns

uint32
uint32
core/plugin/wasm.go:557-559
func unpackWasmBuffer(value uint64) (uint32, uint32)

{
	return uint32(value >> 32), uint32(value)
}
F
function

validateWasmName

Parameters

name
string

Returns

error
core/plugin/wasm.go:561-572
func validateWasmName(name string) error

{
	if len(name) == 0 || len(name) > maxWasmNameSize {
		return fmt.Errorf("must contain 1 to %d bytes", maxWasmNameSize)
	}
	for index, character := range name {
		if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' && index > 0 || index > 0 && strings.ContainsRune("._-", character) {
			continue
		}
		return fmt.Errorf("contains invalid character %q", character)
	}
	return nil
}
F
function

cloneStrings

Parameters

source
map[string]string

Returns

map[string]string
core/plugin/wasm.go:574-583
func cloneStrings(source map[string]string) map[string]string

{
	if source == nil {
		return nil
	}
	cloned := make(map[string]string, len(source))
	for key, value := range source {
		cloned[key] = value
	}
	return cloned
}
S
struct

wasmFunction

core/plugin/wasm_abi.go:10-14
type wasmFunction struct

Fields

Name Type Description
name string
parameters []api.ValueType
results []api.ValueType
F
function

equalValueTypes

Parameters

left
right

Returns

bool
core/plugin/wasm_abi.go:72-82
func equalValueTypes(left, right []api.ValueType) bool

{
	if len(left) != len(right) {
		return false
	}
	for index := range left {
		if left[index] != right[index] {
			return false
		}
	}
	return true
}
F
function

readModuleBytes

Parameters

module
pointer
uint32
length
uint32

Returns

[]byte
bool
core/plugin/wasm_abi.go:170-179
func readModuleBytes(module api.Module, pointer, length uint32) ([]byte, bool)

{
	if length == 0 {
		return nil, true
	}
	data, ok := module.Memory().Read(pointer, length)
	if !ok {
		return nil, false
	}
	return append([]byte(nil), data...), true
}
F
function

writeModuleBytes

Parameters

module
pointer
uint32
capacity
uint32
data
[]byte

Returns

int32
core/plugin/wasm_abi.go:181-192
func writeModuleBytes(module api.Module, pointer, capacity uint32, data []byte) int32

{
	if uint32(len(data)) > capacity {
		return -int32(len(data))
	}
	if len(data) == 0 {
		return 0
	}
	if !module.Memory().Write(pointer, data) {
		return -1
	}
	return int32(len(data))
}
S
struct

wasmFilesystem

core/plugin/wasm_config.go:21-24
type wasmFilesystem struct

Fields

Name Type Description
fs fs.FS
guestPath string
S
struct

wasmConfig

core/plugin/wasm_config.go:26-41
type wasmConfig struct

Methods

validate
Method

Returns

error
func (wasmConfig) validate() error
{
	if config.memoryLimit < wasmPageSize || config.memoryLimit > uint64(math.MaxUint32)+1 {
		return fmt.Errorf("%w: memory limit must be between 64 KiB and 4 GiB", ErrWasmInvalidModule)
	}
	if config.payloadLimit == 0 || config.payloadLimit > math.MaxInt32 {
		return fmt.Errorf("%w: payload limit must be between 1 and %d bytes", ErrWasmInvalidModule, math.MaxInt32)
	}
	for name, capability := range config.capabilities {
		if err := validateWasmName(name); err != nil {
			return fmt.Errorf("%w: capability %q: %v", ErrWasmInvalidModule, name, err)
		}
		if capability == nil {
			return fmt.Errorf("%w: capability %q has no handler", ErrWasmInvalidModule, name)
		}
		if function, ok := capability.(CapabilityFunc); ok && function == nil {
			return fmt.Errorf("%w: capability %q has no handler", ErrWasmInvalidModule, name)
		}
	}
	for key := range config.wasiEnv {
		if strings.TrimSpace(key) == "" || strings.ContainsAny(key, "=\x00") {
			return fmt.Errorf("%w: invalid WASI environment key %q", ErrWasmInvalidModule, key)
		}
	}
	for _, mount := range config.wasiFS {
		if mount.fs == nil || strings.TrimSpace(mount.guestPath) == "" {
			return fmt.Errorf("%w: invalid WASI filesystem mount", ErrWasmInvalidModule)
		}
	}
	return nil
}
memoryPages
Method

Returns

uint32
func (wasmConfig) memoryPages() uint32
{
	return uint32((config.memoryLimit + wasmPageSize - 1) / wasmPageSize)
}

Fields

Name Type Description
capabilities map[string]Capability
memoryLimit uint64
payloadLimit uint32
callTimeout time.Duration
wasi bool
wasiArgs []string
wasiEnv map[string]string
wasiFS []wasmFilesystem
stdin io.Reader
stdout io.Writer
stderr io.Writer
random io.Reader
systemClock bool
cache wazero.CompilationCache
F
function

defaultWasmConfig

Returns

core/plugin/wasm_config.go:43-51
func defaultWasmConfig() wasmConfig

{
	return wasmConfig{
		capabilities: make(map[string]Capability),
		memoryLimit:  defaultWasmMemoryLimit,
		payloadLimit: defaultWasmPayloadLimit,
		callTimeout:  defaultWasmCallTimeout,
		wasiEnv:      make(map[string]string),
	}
}
T
type

WasmOption

WasmOption configures a WebAssembly plugin runtime before the module loads.

core/plugin/wasm_config.go:54-54
type WasmOption func(*wasmConfig)
F
function

WithWasmCapability

WithWasmCapability grants one named host capability to a module.

Parameters

name
string
capability

Returns

core/plugin/wasm_config.go:57-61
func WithWasmCapability(name string, capability Capability) WasmOption

{
	return func(config *wasmConfig) {
		config.capabilities[name] = capability
	}
}
F
function

WithWasmMemoryLimit

WithWasmMemoryLimit sets the maximum linear memory available to a module.

Parameters

bytes
uint64

Returns

core/plugin/wasm_config.go:64-68
func WithWasmMemoryLimit(bytes uint64) WasmOption

{
	return func(config *wasmConfig) {
		config.memoryLimit = bytes
	}
}
F
function

WithWasmPayloadLimit

WithWasmPayloadLimit sets the maximum request, response, and error size.

Parameters

bytes
uint32

Returns

core/plugin/wasm_config.go:71-75
func WithWasmPayloadLimit(bytes uint32) WasmOption

{
	return func(config *wasmConfig) {
		config.payloadLimit = bytes
	}
}
F
function

WithWasmCallTimeout

WithWasmCallTimeout sets the default lifecycle and method call deadline.
A non-positive duration leaves deadlines to the supplied context.

Parameters

timeout

Returns

core/plugin/wasm_config.go:79-83
func WithWasmCallTimeout(timeout time.Duration) WasmOption

{
	return func(config *wasmConfig) {
		config.callTimeout = timeout
	}
}
F
function

WithWasmCompilationCache

WithWasmCompilationCache shares compiled WebAssembly code across runtimes.
The caller owns the cache and must close it after every plugin using it closes.

Parameters

Returns

core/plugin/wasm_config.go:87-91
func WithWasmCompilationCache(cache wazero.CompilationCache) WasmOption

{
	return func(config *wasmConfig) {
		config.cache = cache
	}
}
F
function

WithWasmWASI

WithWasmWASI enables WASI Preview 1 with no inherited host resources.

Returns

core/plugin/wasm_config.go:94-98
func WithWasmWASI() WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
	}
}
F
function

WithWasmWASIArgs

WithWasmWASIArgs exposes an explicit argument vector through WASI.

Parameters

args
...string

Returns

core/plugin/wasm_config.go:101-106
func WithWasmWASIArgs(args ...string) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.wasiArgs = append([]string(nil), args...)
	}
}
F
function

WithWasmWASIEnv

WithWasmWASIEnv exposes one environment variable through WASI.

Parameters

key
string
value
string

Returns

core/plugin/wasm_config.go:109-114
func WithWasmWASIEnv(key, value string) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.wasiEnv[key] = value
	}
}
F
function

WithWasmWASIFS

WithWasmWASIFS mounts an explicit fs.FS at a guest path.
The caller is responsible for the isolation guarantees of the supplied filesystem.

Parameters

filesystem
guestPath
string

Returns

core/plugin/wasm_config.go:118-123
func WithWasmWASIFS(filesystem fs.FS, guestPath string) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.wasiFS = append(config.wasiFS, wasmFilesystem{fs: filesystem, guestPath: guestPath})
	}
}
F
function

WithWasmWASIStdin

WithWasmWASIStdin exposes an explicit input stream through WASI.

Parameters

reader

Returns

core/plugin/wasm_config.go:126-131
func WithWasmWASIStdin(reader io.Reader) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.stdin = reader
	}
}
F
function

WithWasmWASIStdout

WithWasmWASIStdout exposes an explicit output stream through WASI.

Parameters

writer

Returns

core/plugin/wasm_config.go:134-139
func WithWasmWASIStdout(writer io.Writer) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.stdout = writer
	}
}
F
function

WithWasmWASIStderr

WithWasmWASIStderr exposes an explicit error stream through WASI.

Parameters

writer

Returns

core/plugin/wasm_config.go:142-147
func WithWasmWASIStderr(writer io.Writer) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.stderr = writer
	}
}
F
function

WithWasmWASIRandom

WithWasmWASIRandom exposes an explicit random source through WASI.

Parameters

reader

Returns

core/plugin/wasm_config.go:150-155
func WithWasmWASIRandom(reader io.Reader) WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.random = reader
	}
}
F
function

WithWasmWASISystemClock

WithWasmWASISystemClock exposes the host wall clock, monotonic clock, and sleep.

Returns

core/plugin/wasm_config.go:158-163
func WithWasmWASISystemClock() WasmOption

{
	return func(config *wasmConfig) {
		config.wasi = true
		config.systemClock = true
	}
}
F
function

DiscoverWasm

DiscoverWasm loads every .wasm plugin in a directory in filename order.

Parameters

directory
string
options
...WasmOption

Returns

error
core/plugin/wasm_discover.go:13-36
func DiscoverWasm(ctx context.Context, directory string, options ...WasmOption) ([]*WasmPlugin, error)

{
	entries, err := os.ReadDir(directory)
	if err != nil {
		return nil, fmt.Errorf("plugin: read WebAssembly directory: %w", err)
	}
	var paths []string
	for _, entry := range entries {
		if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".wasm") {
			continue
		}
		paths = append(paths, filepath.Join(directory, entry.Name()))
	}
	sort.Strings(paths)
	loaded := make([]*WasmPlugin, 0, len(paths))
	for _, path := range paths {
		module, err := LoadWasmFile(ctx, path, options...)
		if err != nil {
			closeWasm(ctx, loaded)
			return nil, err
		}
		loaded = append(loaded, module)
	}
	return loaded, nil
}
F
function

closeWasm

Parameters

plugins
core/plugin/wasm_discover.go:57-61
func closeWasm(ctx context.Context, plugins []*WasmPlugin)

{
	for _, plugin := range plugins {
		_ = plugin.Close(ctx)
	}
}
F
function

testCapability

Parameters

operation
string
input
[]byte

Returns

[]byte
error
core/plugin/wasm_test.go:18-23
func testCapability(_ context.Context, operation string, input []byte) ([]byte, error)

{
	if operation != "invoke" {
		return nil, errors.New("unexpected operation")
	}
	return append([]byte("host:"), input...), nil
}
F
function

loadTestWasm

Parameters

options
...WasmOption

Returns

core/plugin/wasm_test.go:25-38
func loadTestWasm(t *testing.T, options ...WasmOption) *WasmPlugin

{
	t.Helper()
	options = append(options, WithWasmCapability("test.echo", CapabilityFunc(testCapability)))
	loaded, err := LoadWasm(context.Background(), testWasmPlugin, options...)
	if err != nil {
		t.Fatalf("LoadWasm() error = %v", err)
	}
	t.Cleanup(func() {
		if err := loaded.Close(context.Background()); err != nil {
			t.Errorf("Close() error = %v", err)
		}
	})
	return loaded
}
F
function

TestLoadWasmReadsMetadataAndRequiresCapabilities

Parameters

core/plugin/wasm_test.go:40-63
func TestLoadWasmReadsMetadataAndRequiresCapabilities(t *testing.T)

{
	if _, err := LoadWasm(context.Background(), testWasmPlugin); !errors.Is(err, ErrWasmCapabilityDenied) {
		t.Fatalf("LoadWasm() error = %v, want ErrWasmCapabilityDenied", err)
	}

	loaded := loadTestWasm(t)
	metadata := loaded.Metadata()
	if metadata.Name != "fixture" || metadata.Version != "1.0.0" {
		t.Fatalf("Metadata() = %#v", metadata)
	}
	if !reflect.DeepEqual(metadata.Methods, []string{"echo", "capability", "fail", "hang"}) {
		t.Fatalf("Metadata().Methods = %v", metadata.Methods)
	}
	if metadata.Properties["language"] != "c" {
		t.Fatalf("Metadata().Properties = %v", metadata.Properties)
	}
	metadata.Methods[0] = "changed"
	metadata.Capabilities[0] = "changed"
	metadata.Properties["language"] = "changed"
	stable := loaded.Metadata()
	if stable.Methods[0] != "echo" || stable.Capabilities[0] != "test.echo" || stable.Properties["language"] != "c" {
		t.Fatalf("Metadata() returned mutable state: %#v", stable)
	}
}
F
function

TestDecodeWasmMetadataRejectsTrailingData

Parameters

core/plugin/wasm_test.go:65-70
func TestDecodeWasmMetadataRejectsTrailingData(t *testing.T)

{
	_, err := decodeWasmMetadata([]byte(`{"name":"fixture","version":"1.0.0"}{}`))
	if !errors.Is(err, ErrWasmInvalidModule) {
		t.Fatalf("decodeWasmMetadata() error = %v, want ErrWasmInvalidModule", err)
	}
}
F
function

TestLoadWasmRejectsInvalidConfiguration

Parameters

core/plugin/wasm_test.go:72-90
func TestLoadWasmRejectsInvalidConfiguration(t *testing.T)

{
	tests := []struct {
		name   string
		option WasmOption
	}{
		{name: "memory", option: WithWasmMemoryLimit(1)},
		{name: "payload", option: WithWasmPayloadLimit(0)},
		{name: "capability name", option: WithWasmCapability("Invalid", CapabilityFunc(testCapability))},
		{name: "capability handler", option: WithWasmCapability("test.echo", nil)},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := LoadWasm(context.Background(), testWasmPlugin, test.option)
			if !errors.Is(err, ErrWasmInvalidModule) {
				t.Fatalf("LoadWasm() error = %v, want ErrWasmInvalidModule", err)
			}
		})
	}
}
F
function

TestWasmPluginLifecycleAndCalls

Parameters

core/plugin/wasm_test.go:92-140
func TestWasmPluginLifecycleAndCalls(t *testing.T)

{
	loaded := loadTestWasm(t)
	if _, err := loaded.Call(context.Background(), "echo", []byte("before")); !errors.Is(err, ErrWasmNotStarted) {
		t.Fatalf("Call() before Start() error = %v, want ErrWasmNotStarted", err)
	}
	if err := loaded.Start(); err != nil {
		t.Fatalf("Start() error = %v", err)
	}
	if err := loaded.Start(); err != nil {
		t.Fatalf("second Start() error = %v", err)
	}

	response, err := loaded.Call(context.Background(), "echo", []byte(`{"hello":"world"}`))
	if err != nil {
		t.Fatalf("Call(echo) error = %v", err)
	}
	if string(response) != `{"hello":"world"}` {
		t.Fatalf("Call(echo) = %q", response)
	}

	var decoded map[string]string
	if err := loaded.CallJSON(context.Background(), "echo", map[string]string{"source": "json"}, &decoded); err != nil {
		t.Fatalf("CallJSON() error = %v", err)
	}
	if decoded["source"] != "json" {
		t.Fatalf("CallJSON() = %v", decoded)
	}

	response, err = loaded.Call(context.Background(), "capability", []byte("request"))
	if err != nil {
		t.Fatalf("Call(capability) error = %v", err)
	}
	if string(response) != "host:request" {
		t.Fatalf("Call(capability) = %q", response)
	}

	if _, err := loaded.Call(context.Background(), "missing", nil); !errors.Is(err, ErrWasmCallFailed) {
		t.Fatalf("Call(missing) error = %v, want ErrWasmCallFailed", err)
	}
	if _, err := loaded.Call(context.Background(), "fail", nil); !errors.Is(err, ErrWasmCallFailed) {
		t.Fatalf("Call(fail) error = %v, want ErrWasmCallFailed", err)
	}
	if err := loaded.Stop(); err != nil {
		t.Fatalf("Stop() error = %v", err)
	}
	if err := loaded.Stop(); err != nil {
		t.Fatalf("second Stop() error = %v", err)
	}
}
F
function

TestWasmPluginSerializesConcurrentCalls

Parameters

core/plugin/wasm_test.go:142-164
func TestWasmPluginSerializesConcurrentCalls(t *testing.T)

{
	loaded := loadTestWasm(t)
	if err := loaded.Start(); err != nil {
		t.Fatal(err)
	}

	var group sync.WaitGroup
	for index := 0; index < 16; index++ {
		group.Add(1)
		go func() {
			defer group.Done()
			response, err := loaded.Call(context.Background(), "capability", []byte("parallel"))
			if err != nil {
				t.Errorf("Call() error = %v", err)
				return
			}
			if string(response) != "host:parallel" {
				t.Errorf("Call() = %q", response)
			}
		}()
	}
	group.Wait()
}
F
function

TestWasmPluginClosesAfterDeadline

Parameters

core/plugin/wasm_test.go:166-177
func TestWasmPluginClosesAfterDeadline(t *testing.T)

{
	loaded := loadTestWasm(t, WithWasmCallTimeout(20*time.Millisecond))
	if err := loaded.Start(); err != nil {
		t.Fatal(err)
	}
	if _, err := loaded.Call(context.Background(), "hang", nil); !errors.Is(err, ErrWasmClosed) {
		t.Fatalf("Call(hang) error = %v, want ErrWasmClosed", err)
	}
	if !loaded.Closed() {
		t.Fatal("Closed() = false after deadline")
	}
}
F
function

TestDiscoverWasmAndRegistryCloseAll

Parameters

core/plugin/wasm_test.go:179-208
func TestDiscoverWasmAndRegistryCloseAll(t *testing.T)

{
	directory := t.TempDir()
	path := filepath.Join(directory, "fixture.wasm")
	if err := os.WriteFile(path, testWasmPlugin, 0o600); err != nil {
		t.Fatal(err)
	}
	registry := NewRegistry()
	count, err := registry.RegisterWasmDirectory(
		context.Background(),
		directory,
		WithWasmCapability("test.echo", CapabilityFunc(testCapability)),
	)
	if err != nil {
		t.Fatalf("RegisterWasmDirectory() error = %v", err)
	}
	if count != 1 {
		t.Fatalf("RegisterWasmDirectory() = %d, want 1", count)
	}
	if errs := registry.StartAll(); len(errs) != 0 {
		t.Fatalf("StartAll() errors = %v", errs)
	}
	registered, _ := registry.Get("fixture")
	loaded, _ := registered.(*WasmPlugin)
	if errs := registry.CloseAll(context.Background()); len(errs) != 0 {
		t.Fatalf("CloseAll() errors = %v", errs)
	}
	if !loaded.Closed() {
		t.Fatal("registered plugin remained open")
	}
}