Files
2026-03-13 19:02:42 +02:00

422 lines
14 KiB
Go

// new
package extism
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync/atomic"
"time"
observe "github.com/dylibso/observe-sdk/go"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
type CompiledPlugin struct {
runtime wazero.Runtime
main wazero.CompiledModule
extism wazero.CompiledModule
env api.Module
modules map[string]wazero.CompiledModule
// when a module (main) is instantiated, it may have a module name that's added
// to the data section of the wasm. If this is the case, we won't be able to
// instantiate that module more than once. This counter acts as the module name
// incrementing each time we instantiate the module.
instanceCount atomic.Uint64
// this is the raw wasm bytes of the provided module, it is required when using a tracing observeAdapter.
// If an adapter is not provided, this field will be nil.
wasmBytes []byte
hasWasi bool
manifest Manifest
observeAdapter *observe.AdapterBase
observeOptions *observe.Options
maxHttp int64
maxVar int64
enableHttpResponseHeaders bool
}
type PluginConfig struct {
RuntimeConfig wazero.RuntimeConfig
EnableWasi bool
ObserveAdapter *observe.AdapterBase
ObserveOptions *observe.Options
EnableHttpResponseHeaders bool
// ModuleConfig is only used when a plugins are built using the NewPlugin
// function. In this function, the plugin is both compiled, and an instance
// of the plugin is instantiated, and the ModuleConfig is passed to the
// instance.
//
// When plugins are built using NewCompiledPlugin, the ModuleConfig has no
// effect because the instance is not created. Instead, the ModuleConfig is
// passed directly in calls to the CompiledPlugin.Instance method.
//
// NOTE: Module name and start functions are ignored as they are overridden by Extism, also if Manifest contains
// non-empty AllowedPaths, then FS is also ignored. If EXTISM_ENABLE_WASI_OUTPUT is set, then stdout and stderr are
// set to os.Stdout and os.Stderr respectively (ignoring user defined module config).
ModuleConfig wazero.ModuleConfig
}
// NewPlugin creates compiles and instantiates a plugin that is ready
// to be used. Plugins are not thread-safe. If you need to use a plugin
// across multiple goroutines, use NewCompiledPlugin and create instances
// of the plugin using the CompiledPlugin.Instance method.
func NewPlugin(
ctx context.Context,
manifest Manifest,
config PluginConfig,
functions []HostFunction,
) (*Plugin, error) {
c, err := NewCompiledPlugin(ctx, manifest, config, functions)
if err != nil {
return nil, err
}
p, err := c.Instance(ctx, PluginInstanceConfig{
ModuleConfig: config.ModuleConfig,
})
if err != nil {
return nil, err
}
p.close = append(p.close, c.Close)
return p, nil
}
func calculateMaxHttp(manifest Manifest) int64 {
// Default is 50MB
maxHttp := int64(1024 * 1024 * 50)
if manifest.Memory != nil && manifest.Memory.MaxHttpResponseBytes >= 0 {
maxHttp = manifest.Memory.MaxHttpResponseBytes
}
return maxHttp
}
func calculateMaxVar(manifest Manifest) int64 {
// Default is 1MB
maxVar := int64(1024 * 1024)
if manifest.Memory != nil && manifest.Memory.MaxVarBytes >= 0 {
maxVar = manifest.Memory.MaxVarBytes
}
return maxVar
}
// NewCompiledPlugin creates a compiled plugin that is ready to be instantiated.
// You can instantiate the plugin multiple times using the CompiledPlugin.Instance
// method and run those instances concurrently.
func NewCompiledPlugin(
ctx context.Context,
manifest Manifest,
config PluginConfig,
funcs []HostFunction,
) (*CompiledPlugin, error) {
count := len(manifest.Wasm)
if count == 0 {
return nil, fmt.Errorf("manifest can't be empty")
}
runtimeConfig := config.RuntimeConfig
if runtimeConfig == nil {
runtimeConfig = wazero.NewRuntimeConfig()
}
// Make sure function calls are cancelled if the context is cancelled
if manifest.Timeout > 0 {
runtimeConfig = runtimeConfig.WithCloseOnContextDone(true)
}
if manifest.Memory != nil {
if manifest.Memory.MaxPages > 0 {
runtimeConfig = runtimeConfig.WithMemoryLimitPages(manifest.Memory.MaxPages)
}
}
p := CompiledPlugin{
manifest: manifest,
runtime: wazero.NewRuntimeWithConfig(ctx, runtimeConfig),
observeAdapter: config.ObserveAdapter,
observeOptions: config.ObserveOptions,
enableHttpResponseHeaders: config.EnableHttpResponseHeaders,
modules: make(map[string]wazero.CompiledModule),
maxHttp: calculateMaxHttp(manifest),
maxVar: calculateMaxVar(manifest),
}
if config.EnableWasi {
wasi_snapshot_preview1.MustInstantiate(ctx, p.runtime)
p.hasWasi = true
}
// Build host modules
hostModules := make(map[string][]HostFunction)
for _, f := range funcs {
hostModules[f.Namespace] = append(hostModules[f.Namespace], f)
}
for name, funcs := range hostModules {
_, err := buildHostModule(ctx, p.runtime, name, funcs)
if err != nil {
return nil, fmt.Errorf("building host module: %w", err)
}
}
// Compile the extism module
var err error
p.extism, err = p.runtime.CompileModule(ctx, extismRuntimeWasm)
if err != nil {
return nil, fmt.Errorf("instantiating extism module: %w", err)
}
// Build and instantiate extism:host/env module
p.env, err = instantiateEnvModule(ctx, p.runtime)
if err != nil {
return nil, err
}
// Try to find the main module:
// - There is always one main module
// - If a Wasm value has the Name field set to "main" then use that module
// - If there is only one module in the manifest then that is the main module by default
// - Otherwise the last module listed is the main module
foundMain := false
for i, wasm := range manifest.Wasm {
data, err := wasm.ToWasmData(ctx)
if err != nil {
return nil, err
}
if (data.Name == "" || i == len(manifest.Wasm)-1) && !foundMain {
data.Name = "main"
}
_, okm := p.modules[data.Name]
if data.Name == "extism:host/env" || okm {
return nil, fmt.Errorf("module name collision: '%s'", data.Name)
}
if data.Hash != "" {
calculatedHash := calculateHash(data.Data)
if data.Hash != calculatedHash {
return nil, fmt.Errorf("hash mismatch for module '%s'", data.Name)
}
}
if p.observeAdapter != nil {
p.wasmBytes = data.Data
}
compiledModule, err := p.runtime.CompileModule(ctx, data.Data)
if err != nil {
return nil, err
}
if data.Name == "main" {
if foundMain {
return nil, errors.New("can't have more than one main module")
}
p.main = compiledModule
foundMain = true
} else {
// Store compiled module for instantiation
p.modules[data.Name] = compiledModule
// Create wrapper with original name that will forward calls to the actual module instance. See createModuleWrapper for more details.
_, err = createModuleWrapper(ctx, p.runtime, data.Name, compiledModule)
if err != nil {
return nil, fmt.Errorf("failed to create wrapper for %s: %w", data.Name, err)
}
}
}
if p.main == nil {
return nil, errors.New("no main module found")
}
// We no longer need the wasm in the manifest so nil it
// to make the slice eligible for garbage collection.
p.manifest.Wasm = nil
return &p, nil
}
// createModuleWrapper creates a host module that acts as a proxy for module instances.
// In Wazero, modules with the same name cannot be instantiated multiple times in the same runtime.
// However, we need each Plugin instance to have its own copy of each module for isolation. To solve this, we:
// 1. Create a host module wrapper that keeps the original module name (needed for imports to work)
// 2. Instantiate actual module copies with unique names for each Plugin
// 3. The wrapper forwards function calls to the correct module instance for each Plugin
func createModuleWrapper(ctx context.Context, rt wazero.Runtime, name string, compiled wazero.CompiledModule) (api.Module, error) {
builder := rt.NewHostModuleBuilder(name)
// Create proxy functions for each exported function from the original module.
// These proxies will forward calls to the appropriate module instance.
for _, export := range compiled.ExportedFunctions() {
exportName := export.Name()
// Skip wrapping the _start function since it's automatically called by wazero during instantiation.
// The wrapper functions require a Plugin instance in the context to work, but during wrapper
// instantiation there is no Plugin instance yet.
if exportName == "_start" {
continue
}
// Create a proxy function that:
// 1. Gets the calling Plugin instance from context
// 2. Looks up that Plugin's copy of this module
// 3. Forwards the call to the actual function
wrapper := func(callCtx context.Context, mod api.Module, stack []uint64) {
// Get the Plugin instance that's making this call
plugin, ok := callCtx.Value(PluginCtxKey("plugin")).(*Plugin)
if !ok {
panic("Invalid context, `plugin` key not found")
}
// Get this Plugin's instance of the module
actualModule, ok := plugin.modules[name]
if !ok {
panic(fmt.Sprintf("module %s not found in plugin", name))
}
// Forward the call to the actual module instance
fn := actualModule.ExportedFunction(exportName)
if fn == nil {
panic(fmt.Sprintf("function %s not found in module %s", exportName, name))
}
err := fn.CallWithStack(callCtx, stack)
if err != nil {
panic(err)
}
}
// Export the proxy function with the same name and signature as the original
builder.NewFunctionBuilder().
WithGoModuleFunction(api.GoModuleFunc(wrapper), export.ParamTypes(), export.ResultTypes()).
Export(exportName)
}
return builder.Instantiate(ctx)
}
func (p *CompiledPlugin) Close(ctx context.Context) error {
return p.runtime.Close(ctx)
}
func (p *CompiledPlugin) Instance(ctx context.Context, config PluginInstanceConfig) (*Plugin, error) {
instanceNum := p.instanceCount.Add(1)
var closers []func(ctx context.Context) error
moduleConfig := config.ModuleConfig
if moduleConfig == nil {
moduleConfig = wazero.NewModuleConfig()
}
// NOTE: we don't want wazero to call the start function, we will initialize
// the guest runtime manually.
// See: https://github.com/extism/go-sdk/pull/1#issuecomment-1650527495
moduleConfig = moduleConfig.WithStartFunctions()
if len(p.manifest.AllowedPaths) > 0 {
// NOTE: this is only necessary for guest modules because
// host modules have the same access privileges as the host itself
fs := wazero.NewFSConfig()
for host, guest := range p.manifest.AllowedPaths {
if strings.HasPrefix(host, "ro:") {
trimmed := strings.TrimPrefix(host, "ro:")
fs = fs.WithReadOnlyDirMount(trimmed, guest)
} else {
fs = fs.WithDirMount(host, guest)
}
}
moduleConfig = moduleConfig.WithFSConfig(fs)
}
_, wasiOutput := os.LookupEnv("EXTISM_ENABLE_WASI_OUTPUT")
if p.hasWasi && wasiOutput {
moduleConfig = moduleConfig.WithStderr(os.Stderr).WithStdout(os.Stdout)
}
var trace *observe.TraceCtx
var err error
if p.observeAdapter != nil {
trace, err = p.observeAdapter.NewTraceCtx(ctx, p.runtime, p.wasmBytes, p.observeOptions)
if err != nil {
return nil, fmt.Errorf("failed to initialize Observe Adapter: %v", err)
}
}
// Compile and instantiate the extism runtime. This runtime is stateful and needs to be
// instantiated on a per-instance basis. We don't provide a name because the module needs
// to be anonymous -- you cannot instantiate multiple modules with the same name into the
// same runtime. It is okay that this is anonymous, because this module is only called
// from Go host functions and not from the Wasm module itself.
extism, err := p.runtime.InstantiateModule(ctx, p.extism, wazero.NewModuleConfig())
if err != nil {
return nil, fmt.Errorf("instantiating extism module: %w", err)
}
closers = append(closers, extism.Close)
// Instantiate all non-main modules first
instancedModules := make(map[string]api.Module)
for name, compiledModule := range p.modules {
uniqueName := fmt.Sprintf("%s_%d", name, instanceNum)
instance, err := p.runtime.InstantiateModule(ctx, compiledModule, moduleConfig.WithName(uniqueName))
if err != nil {
for _, closer := range closers {
closer(ctx)
}
return nil, fmt.Errorf("instantiating module %s: %w", name, err)
}
instancedModules[name] = instance
closers = append(closers, instance.Close)
}
mainModuleName := fmt.Sprintf("main_%d", instanceNum)
main, err := p.runtime.InstantiateModule(ctx, p.main, moduleConfig.WithName(mainModuleName))
if err != nil {
for _, closer := range closers {
closer(ctx)
}
return nil, fmt.Errorf("instantiating module: %w", err)
}
closers = append(closers, main.Close)
var headers map[string]string = nil
if p.enableHttpResponseHeaders {
headers = map[string]string{}
}
instance := &Plugin{
close: closers,
extism: extism,
hasWasi: p.hasWasi,
mainModule: main,
modules: instancedModules,
Timeout: time.Duration(p.manifest.Timeout) * time.Millisecond,
Config: p.manifest.Config,
Var: make(map[string][]byte),
AllowedHosts: p.manifest.AllowedHosts,
AllowedPaths: p.manifest.AllowedPaths,
LastStatusCode: 0,
LastResponseHeaders: headers,
MaxHttpResponseBytes: p.maxHttp,
MaxVarBytes: p.maxVar,
guestRuntime: guestRuntime{},
Adapter: p.observeAdapter,
log: logStd,
traceCtx: trace,
}
instance.guestRuntime = detectGuestRuntime(instance)
return instance, nil
}