core/plugin/wasmguest/exports.go
Alex Dunmow c35199f0bc feat(wasmguest): guest runtime shim for the wasm plugin ABI (WO-WZ-002)
The wasip1 half of the ABI: bn_alloc/bn_invoke/bn_free exports with a
live-pin map so the GC never frees host-visible buffers, the generic
`blockninja.host_call` import (single import decided over per-family
symbols; recorded in wasm-abi.md), and a dispatch table adapting an
unmodified plugin.PluginRegistration to all nine v1 hooks. Panics inside
plugin hooks come back as ABI_ERROR_CODE_INTERNAL — the instance stays
callable; traps stay reserved for runtime corruption.

DESCRIBE builds the PluginManifest from the registration's static funcs
plus a capture-only Register pass (block metas via the same
PluginBlockRegistry prefixing the .so loader applies, template/system/
page-template/email-wrapper keys), probes JobHandlers/ServiceHandlers/
Load with capture-only services for job types, RBAC roles, core-service
bindings, and RAG fetcher types. RenderContext values are rehydrated
through the exact core/blocks context keys, so existing block code
reading from ctx works unchanged.

Plugins build in REACTOR mode (go build -buildmode=c-shared): init()
calls wasmguest.Serve (non-blocking), main is never called, and the host
runs _initialize before any bn_invoke. Command mode deadlocks or exits
(verified against wazero v1.12.0) — documented prominently in
wasm-abi.md, which also now reconciles the import module namespace to
`blockninja` and requires bn_alloc'd buffers on both directions.

Dispatch/describe/context logic is buildable on every GOOS; only
exports.go and hostcalls.go carry the wasip1 tag. dispatch_test.go
covers describe, hook routing, envelope mismatch, decode failures,
template-override resolution, panic recovery, and lifecycle hooks
natively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:24 +08:00

90 lines
2.7 KiB
Go

//go:build wasip1
package wasmguest
import (
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"unsafe"
)
// pinned keeps host-visible buffers reachable so the Go GC never frees (or,
// under a future moving GC, relocates) memory the host holds a raw pointer
// into. Keyed by the buffer's linear-memory address. Wasm instances are
// single-threaded per the ABI (one bn_invoke at a time), so no locking.
var pinned = make(map[uint32][]byte)
// lastResponse pins the most recent bn_invoke response buffer; per the ABI
// it stays valid until the next bn_invoke on this instance.
var lastResponse []byte
// bn_alloc allocates size bytes of guest memory for the host to write into.
// The region stays valid until the current bn_invoke call returns (request
// buffers) or until released with bn_free / consumed by the shim (host-call
// responses).
//
//go:wasmexport bn_alloc
func bnAlloc(size uint32) uint32 {
if size == 0 {
return 0
}
buf := make([]byte, size)
ptr := bufPtr(buf)
pinned[ptr] = buf
return ptr
}
// bn_free releases a buffer previously returned by bn_alloc. Unknown
// pointers are ignored (the shim may have already released the buffer).
//
//go:wasmexport bn_free
func bnFree(ptr uint32) {
delete(pinned, ptr)
}
// bn_invoke dispatches one hook call. (ptr, len) frames a serialized
// InvokeRequest written by the host into bn_alloc'd memory; the packed
// return value ((ptr << 32) | len) frames a serialized InvokeResponse valid
// until the next bn_invoke. A return of 0 means the guest could not even
// produce an error envelope; the host treats it as INTERNAL and discards
// the instance.
//
//go:wasmexport bn_invoke
func bnInvoke(hookID uint32, ptr uint32, length uint32) uint64 {
var (
req []byte
known bool
)
if buf, ok := pinned[ptr]; ok && uint32(len(buf)) >= length {
req = buf[:length:length]
known = true
}
var resp []byte
switch {
case !known:
// The ABI requires request buffers to come from bn_alloc; anything
// else is a protocol violation, not readable guest memory.
resp = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE,
"bn_invoke request pointer was not allocated via bn_alloc")
case current == nil:
resp = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL,
"wasmguest.Serve was never called — plugin main package is missing the init boilerplate (see wasmguest doc.go)")
default:
resp = current.invoke(hookID, req)
}
// Request buffer ownership ends with this call (ABI: valid until
// bn_invoke returns). Release it whether or not the host calls bn_free.
delete(pinned, ptr)
lastResponse = resp
if len(resp) == 0 {
return 0
}
return uint64(bufPtr(resp))<<32 | uint64(uint32(len(resp)))
}
func bufPtr(b []byte) uint32 {
return uint32(uintptr(unsafe.Pointer(&b[0])))
}