Files
OwnCord/Server/plugin/sandbox_wazero.go
T
J3vbandClaude Fable 5 b13adf23aa fix(plugin): re-instantiate wazero module after CPU-budget overrun (W1-1)
WithCloseOnContextDone(true) closes the module when the per-call budget
deadline fires, and nothing ever re-instantiated it — one over-budget
command bricked the plugin for every user until an admin disable/enable
cycle or a server restart. Now any guest-call failure that closed the
module (deadline, trap, parent-context cancellation) releases inst.module,
and the next dispatch lazily re-activates the same instance; a concurrent-
activation guard keeps double dispatches from leaking modules. Re-
instantiation resets guest in-memory state — documented at the budget site.

Host-call time exclusion from the budget is documented as a requirement but
not implemented: no host imports are wired into the runtime yet, so there
is no host-call time to exclude today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 08:21:54 +02:00

361 lines
14 KiB
Go

//go:build wazero
// Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with
// `-tags wazero`; matches the otel build-tag pattern used
// elsewhere in the repo so the default sqlite-only build does not pull
// wazero into go.mod at runtime.
//
// # Architecture
//
// The wazero-tagged build provides:
//
// platformInit — creates the shared wazero.Runtime (with the
// configured memory cap and WASI preview-1 imports)
// and returns a teardown closure consumed by
// Registry.Close.
// activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates
// it against the shared runtime, stores the resulting
// api.Module on the Instance, and auto-registers any
// commands the module exports via list_commands.
// platformDeactivate — closes the per-plugin module without tearing down
// the shared runtime; called by DisablePlugin so a
// disabled plugin frees memory immediately.
// invokeCommand — calls the plugin's `command_dispatch` export using
// the JSON-ABI:
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (ptr u32, len u32)
// deallocate(ptr u32, len u32)
// Both input and output payloads are JSON.
//
// Plugins that do not export command_dispatch / allocate are still loadable
// — DispatchCommand reports a user-facing diagnostic instead of crashing.
package plugin
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
// wazeroPageBytes is the size of a single WASM linear-memory page (64 KiB).
const wazeroPageBytes = 65536
// platformInit stands up the shared wazero runtime for this Registry. The
// runtime is the top-level handle that owns compiled modules, host modules,
// and per-instance linear memory; every plugin in this registry shares it.
//
// The configured MaxMemoryMB cap is translated to wazero's per-page memory
// limit (64 KiB pages) and applied at runtime construction. WASI preview-1
// is pre-instantiated so plugins compiled with the standard TinyGo / Rust
// wasm32-wasi targets can resolve their syscall imports.
func platformInit(cfg Config) (any, func(context.Context) error, error) {
ctx := context.Background()
memMB := cfg.MaxMemoryMB
if memMB <= 0 {
memMB = 64 // default 64 MiB per plugin runtime
}
memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().
WithMemoryLimitPages(memPages).
WithCloseOnContextDone(true),
)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err)
}
closeFn := func(shutCtx context.Context) error {
return rt.Close(shutCtx)
}
return rt, closeFn, nil
}
// activateWithRuntime compiles inst.WASMPath into a wazero module and
// instantiates it under the shared runtime. The resulting api.Module is
// stashed on inst.module so lifecycle teardown (Close, DisablePlugin) can
// free it without walking the registry again.
//
// The runtime is passed in by Registry.activate as a captured snapshot so
// this function never re-reads r.runtimePlatform — that field can be nil-ed
// concurrently by Close, but the snapshot remains valid (the wazero runtime
// itself returns an error gracefully if it has been closed underneath us).
func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *Instance) error {
rt, ok := platform.(wazero.Runtime)
if !ok || rt == nil {
return fmt.Errorf("plugin %q: wazero runtime unavailable", inst.Manifest.Name)
}
wasmBytes, err := os.ReadFile(inst.WASMPath)
if err != nil {
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
}
// CompileModule is CPU-bound; do it without holding the registry lock.
compiled, err := rt.CompileModule(ctx, wasmBytes)
if err != nil {
return fmt.Errorf("plugin %q: compile: %w", inst.Manifest.Name, err)
}
// Each plugin gets its own module name so multiple instances can coexist
// without colliding in the runtime's global module namespace. Output is
// swallowed so a misbehaving plugin can't flood server logs. _start is
// suppressed so exports are only invoked on demand.
modCfg := wazero.NewModuleConfig().
WithName(inst.Manifest.Name).
WithStdin(strings.NewReader("")). // prevent plugins from reading server stdin
WithStdout(io.Discard).
WithStderr(io.Discard).
WithStartFunctions()
module, err := rt.InstantiateModule(ctx, compiled, modCfg)
if err != nil {
_ = compiled.Close(ctx)
return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err)
}
// Store the module under the lock, then auto-bind any commands the plugin
// exports via list_commands. Binding is routed through RegisterCommand so
// each name goes through the same normalization (trim "/" + lowercase) and
// conflict check the direct registration path uses: a command already owned
// by a DIFFERENT plugin is refused rather than silently clobbered, closing
// the cross-plugin command-hijack hole. RegisterCommand acquires r.mu
// itself, so it is called outside the lock below to avoid re-entrant
// locking. The plugin must also have declared the `commands` capability in
// its manifest, otherwise no binding happens.
r.mu.Lock()
if inst.module != nil {
// Lost a concurrent activation race (e.g. two dispatches both saw a
// closed module); keep the winner's module and discard ours. The
// winner already handled command binding.
r.mu.Unlock()
_ = module.Close(ctx)
return nil
}
inst.module = module
r.mu.Unlock()
if inst.Manifest.HasCapability(CapCommands) {
for _, cmd := range listExportedCommands(ctx, module) {
if err := r.RegisterCommand(cmd, inst); err != nil {
slog.Warn("plugin: skipping command binding",
"plugin", inst.Manifest.Name, "command", cmd, "err", err)
}
}
}
return nil
}
// platformDeactivate closes the wazero module held by inst without touching
// the shared runtime. Safe to call on an instance that was never activated.
// Called from DisablePlugin and Close.
func (r *Registry) platformDeactivate(inst *Instance) {
if inst == nil || inst.module == nil {
return
}
if mod, ok := inst.module.(api.Module); ok {
_ = mod.Close(context.Background())
}
inst.module = nil
}
// invokeCommand calls the plugin's exported `command_dispatch` function
// using a small JSON ABI:
//
// allocate(size u32) → ptr u32
// command_dispatch(ptr u32, len u32) → (result_ptr u32, result_len u32)
// deallocate(ptr u32, len u32)
//
// Both the input and output payloads are JSON. A plugin that does not
// export command_dispatch returns (nil, false) so the WS dispatcher can
// fall back to the not-found response. A plugin that exports
// command_dispatch but lacks allocate returns a user-facing diagnostic.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
if inst == nil {
return nil, false
}
r.mu.RLock()
moduleAny := inst.module
enabled := inst.Enabled
r.mu.RUnlock()
if moduleAny == nil {
// A previous CPU-budget overrun or guest trap closed the module
// (releaseClosedModule cleared it). Re-instantiate lazily so one bad
// command doesn't brick the plugin until an admin disable/enable
// cycle or a server restart. Re-instantiation resets the guest's
// in-memory state. Disabled plugins stay dark.
if !enabled {
return nil, false
}
if err := r.activate(ctx, inst); err != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: reactivate: %v", inst.Manifest.Name, err)}, true
}
r.mu.RLock()
moduleAny = inst.module
r.mu.RUnlock()
if moduleAny == nil {
return nil, false
}
}
mod, ok := moduleAny.(api.Module)
if !ok {
return nil, false
}
dispatchFn := mod.ExportedFunction("command_dispatch")
if dispatchFn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd),
}, true
}
allocFn := mod.ExportedFunction("allocate")
deallocFn := mod.ExportedFunction("deallocate")
if allocFn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %s: missing allocate export (required for command dispatch)", inst.Manifest.Name),
}, true
}
type dispatchPayload struct {
UserID int64 `json:"user_id"`
ChannelID int64 `json:"channel_id"`
Command string `json:"command"`
Args []string `json:"args"`
}
payload, err := json.Marshal(dispatchPayload{
UserID: userID, ChannelID: channelID, Command: cmd, Args: args,
})
if err != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: marshal payload: %v", inst.Manifest.Name, err)}, true
}
// Enforce the plugin's CPU budget. The effective budget is the manifest's
// Resources.CPUBudgetMs, falling back to the configured default, then a
// hard 100ms floor so a zero/negative value can never mean "no limit".
// Every guest call (allocate / command_dispatch / deallocate) runs under
// this deadline instead of the long-lived WebSocket context. The runtime
// was created WithCloseOnContextDone(true), so an expired deadline closes
// the module and interrupts a runaway guest (e.g. `for {}`) — the Call
// returns an error rather than panicking, which the paths below surface,
// and releaseClosedModule then marks the instance for lazy
// re-instantiation on the next dispatch.
//
// The budget is wall-clock over guest execution. No host imports are
// wired into the runtime yet, so host-call time cannot be attributed to
// the guest today; when host functions (host_http, host_storage, …) land,
// their execution time must be excluded from this budget — otherwise the
// floor would kill any command performing a host HTTP call (httpTimeout
// is 10s against a 100ms floor).
budgetMs := inst.Manifest.Resources.CPUBudgetMs
if budgetMs <= 0 {
budgetMs = r.cfg.CPUBudgetMs
}
if budgetMs <= 0 {
budgetMs = 100
}
callCtx, cancel := context.WithTimeout(ctx, time.Duration(budgetMs)*time.Millisecond)
defer cancel()
// Allocate guest memory for the input payload.
size := uint64(len(payload))
ptrs, callErr := allocFn.Call(callCtx, size)
if callErr != nil || len(ptrs) == 0 {
r.releaseClosedModule(inst, mod)
return &CommandResult{Reply: fmt.Sprintf("plugin %s: allocate(%d): %v", inst.Manifest.Name, size, callErr)}, true
}
ptr := ptrs[0]
mem := mod.Memory()
if !mem.Write(uint32(ptr), payload) {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: memory write at %d failed", inst.Manifest.Name, ptr)}, true
}
results, callErr := dispatchFn.Call(callCtx, ptr, size)
// Free the input buffer regardless of dispatch outcome.
if deallocFn != nil {
_, _ = deallocFn.Call(callCtx, ptr, size)
}
if callErr != nil {
// A failed guest call may have closed the module (deadline, trap,
// parent-context cancellation); release it so the next dispatch
// re-instantiates instead of dispatching into a dead module forever.
r.releaseClosedModule(inst, mod)
// Surface a CPU-budget overrun as a clean, specific error rather than
// leaking the raw "module closed with context deadline exceeded".
if callCtx.Err() == context.DeadlineExceeded {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: command exceeded CPU budget of %dms", inst.Manifest.Name, budgetMs)}, true
}
return &CommandResult{Reply: fmt.Sprintf("plugin %s: dispatch: %v", inst.Manifest.Name, callErr)}, true
}
if len(results) < 2 {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: command_dispatch returned %d values, want 2", inst.Manifest.Name, len(results))}, true
}
resPtr, resLen := uint32(results[0]), uint32(results[1])
resBytes, ok2 := mem.Read(resPtr, resLen)
if !ok2 {
return &CommandResult{Reply: fmt.Sprintf("plugin %s: cannot read result at %d+%d", inst.Manifest.Name, resPtr, resLen)}, true
}
type dispatchResult struct {
Reply string `json:"reply"`
}
var dr dispatchResult
if err := json.Unmarshal(resBytes, &dr); err != nil {
// Fall back to raw bytes if the JSON is malformed.
return &CommandResult{Reply: string(resBytes)}, true
}
return &CommandResult{Reply: dr.Reply}, true
}
// releaseClosedModule drops inst.module when the wazero runtime closed it out
// from under us (CPU-budget deadline via WithCloseOnContextDone, a guest
// trap, or parent-context cancellation), so the next dispatch lazily
// re-instantiates the plugin instead of erroring on a dead module forever.
// The pointer guard keeps a concurrent re-activation's fresh module intact.
func (r *Registry) releaseClosedModule(inst *Instance, mod api.Module) {
if !mod.IsClosed() {
return
}
r.mu.Lock()
if inst.module == mod {
inst.module = nil
}
r.mu.Unlock()
}
// listExportedCommands calls the plugin's optional `list_commands` export
// which returns (ptr u32, len u32) pointing to a JSON array of command name
// strings. If the export is absent or returns invalid JSON, an empty slice
// is returned and no command bindings are created.
func listExportedCommands(ctx context.Context, mod api.Module) []string {
fn := mod.ExportedFunction("list_commands")
if fn == nil {
return nil
}
results, err := fn.Call(ctx)
if err != nil || len(results) < 2 {
return nil
}
ptr, length := uint32(results[0]), uint32(results[1])
raw, ok := mod.Memory().Read(ptr, length)
if !ok {
return nil
}
var cmds []string
if err := json.Unmarshal(raw, &cmds); err != nil {
return nil
}
return cmds
}