Files
OwnCord/Server/plugin/sandbox_wazero.go
T
Claude 47d848ee0a feat(phase-bc): implement real OTel + Wazero runtimes; harden install path
Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.

Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
  modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
  wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
  meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
  a concurrent NewAppMetrics() could observe a swapped provider but read
  stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
  failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
  uint64 values that exceed math.MaxInt64 fall back to a STRING attr
  rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
  histogram recording, shutdown idempotency, AppMetrics rebind, and
  the uint64 overflow fallback.

Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
  pre-instantiated; activateWithRuntime compiles + instantiates each
  plugin module under that runtime; platformDeactivate closes per-
  plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
  freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
  a parameter to activateWithRuntime; the call no longer re-reads the
  field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
  present; missing/broken exports return a user-facing diagnostic
  instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
  re-enable after disable (verifies the leak fix), close-twice safety,
  invalid wasm rejection, and DispatchCommand with a missing export.
  Fixture is a 41-byte embedded add.wasm; no external asset required.

Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
  Content-Type is not application/zip|x-zip-compressed|octet-stream
  (415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
  zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
  / size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
  content-type rejection, magic rejection, happy path, lifecycle 503,
  invalid id, and isZipContentType / hasZipMagic helpers.

Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
  glob to include src/**/*.test.tsx so Badge.test.tsx is actually
  discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
  instead of "*", and exposes a destroy() that detaches the message
  listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
  "solid-js/web" (the latter does not re-export it), unblocking
  npx tsc --noEmit.

Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
  api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.

PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
2026-04-06 21:46:22 +00:00

164 lines
6.6 KiB
Go

//go:build wazero
// Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with
// `-tags wazero`; matches the postgres / 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 and returns a
// teardown closure consumed by Registry.Close.
// activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates
// it against the shared runtime with WASI enabled,
// and stores the resulting api.Module on the Instance.
// platformDeactivate — closes the per-plugin module without tearing down
// the shared runtime.
// invokeCommand — calls the plugin's `command_dispatch` export when
// present. The initial wiring keeps the host/guest
// protocol intentionally small: `command_dispatch()`
// takes no parameters and returns a single i32 status
// code. A future iteration will extend this to pass
// command text via guest memory and return a reply.
//
// Any .wasm that does not export command_dispatch is still valid — DispatchCommand
// reports a user-facing "no command_dispatch export" message so operators can
// diagnose mis-built plugins without crashing the server.
package plugin
import (
"context"
"fmt"
"os"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
// 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.
func platformInit(cfg Config) (any, func(context.Context) error, error) {
ctx := context.Background()
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().
WithCloseOnContextDone(true),
)
// WASI is required for TinyGo/Rust plugins that link against the
// standard library; without it even a `main` entrypoint that prints
// anything will fail to instantiate.
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err)
}
_ = cfg // HTTPAllowlist / resource caps are applied per-module in activateWithRuntime
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)
}
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 to keep misbehaving plugins from flooding server logs.
modCfg := wazero.NewModuleConfig().
WithName(inst.Manifest.Name).
WithStdout(discardWriter{}).
WithStderr(discardWriter{})
module, err := rt.InstantiateModule(ctx, compiled, modCfg)
if err != nil {
return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err)
}
inst.module = module
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.
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 is the command-capability entrypoint. The host-guest protocol
// is deliberately minimal in this first iteration:
//
// - If the plugin exports `command_dispatch` with signature `() -> i32`, the
// host calls it. A return value of 0 is treated as success; any non-zero
// value becomes an error reply.
// - If the export is absent, the host returns a user-facing diagnostic.
//
// The plan is to extend this to pass the command string + args through guest
// memory (alloc/free host-side helpers) once the first real plugin needs it.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
_ = userID
_ = channelID
_ = args
if inst == nil || inst.module == nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q is not activated", cmd)}, true
}
mod, ok := inst.module.(api.Module)
if !ok {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: module type mismatch", inst.Manifest.Name)}, true
}
fn := mod.ExportedFunction("command_dispatch")
if fn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd),
}, true
}
res, err := fn.Call(ctx)
if err != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch errored: %v", inst.Manifest.Name, err)}, true
}
status := uint64(0)
if len(res) > 0 {
status = res[0]
}
if status != 0 {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch returned status %d", inst.Manifest.Name, status)}, true
}
return &CommandResult{Reply: fmt.Sprintf("plugin %q: /%s ok", inst.Manifest.Name, cmd)}, true
}
// discardWriter is a tiny io.Writer that throws everything away. Wazero's
// ModuleConfig accepts any io.Writer for stdout/stderr; using io.Discard would
// pull the extra import just to satisfy two calls.
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }