From f76c5fc3b61da06c969fd607c053df5dd0605463 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 22 Aug 2026 15:57:38 +0000 Subject: [PATCH 1/3] feat: update README and WASM memory management for TinyGo compatibility --- README.md | 37 ++++++++++++++++++++++++++++++++++--- wasm_backends.go | 12 ++++++++++-- wasm_exports.go | 15 +++++++++++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9594372..8f9b5a1 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,44 @@ func main() { ## Building for Paca -Paca backend plugins must be compiled to WebAssembly using the WASI preview 1 target (`wasip1`). +Paca backend plugins must be compiled to WebAssembly using the WASI preview 1 target (`wasip1`), with TinyGo: ```bash -GOARCH=wasm GOOS=wasip1 go build -o plugin.wasm main.go +tinygo build -target=wasip1 -buildmode=c-shared -o plugin.wasm main.go ``` -> **Note**: Using [TinyGo](https://tinygo.org/) is strongly recommended for producing smaller WASM binaries (often < 1MB vs ~10MB with standard Go). +`-buildmode=c-shared` is required, not optional: without it, TinyGo doesn't +wire up the WASI reactor entry point (`_initialize`) that this SDK's +runtime depends on, and every call into the plugin panics at runtime with +`"//go:wasmexport function called before runtime initialization"` — it +still compiles fine without the flag, so this only surfaces once the +plugin is actually loaded by the host. + +TinyGo is strongly recommended over the standard Go compiler: it doesn't +statically link Go's full runtime (GC, scheduler, reflection), so a typical +plugin binary is well under 1MB versus several MB with standard Go — and +since every loaded plugin gets its own independent copy of that runtime on +the host, this is a real difference in the host's memory use per plugin, +not just download size. + +Standard Go 1.21+ also works (`GOARCH=wasm GOOS=wasip1 go build +-buildmode=c-shared -o plugin.wasm main.go`) and is the right fallback if +your plugin hits a TinyGo compatibility limitation — TinyGo implements a +subset of the stdlib, notably around `reflect`. One TinyGo-specific +constraint either way: if your plugin declares its own `//go:wasmimport` +host function beyond what this SDK provides (see [`CallHostFunction`](./custom_host_call.go)), +call it directly by name rather than passing it as a value — TinyGo +doesn't support taking the address of a `go:wasmimport` function, only +calling it. If you use `CallHostFunction`, wrap your import in a small +closure: + +```go +// Works under both standard Go and TinyGo: +err := plugin.CallHostFunction(func(a, b, c, d int64) { hostMyImport(a, b, c, d) }, req, &res) + +// Compiles under standard Go, but fails to build under TinyGo: +err := plugin.CallHostFunction(hostMyImport, req, &res) +``` ## API Reference diff --git a/wasm_backends.go b/wasm_backends.go index 28e9b45..780b04e 100644 --- a/wasm_backends.go +++ b/wasm_backends.go @@ -12,8 +12,16 @@ import ( // ── Memory management ───────────────────────────────────────────────────────── // mallocBuffer is a pre-allocated buffer for host-managed memory allocations. -// The host writes request data into this buffer and reads response data from it. -var mallocBuffer [10 * 1024 * 1024]byte +// The host writes request data into this buffer and reads response data from +// it. Sized to match services/api's DefaultResourceLimits.MaxRequestBodyBytes +// (paca/services/api/internal/platform/plugin/runtime.go) — the host already +// rejects any payload larger than that before ever attempting to write it +// here, so this only needs to cover the same ceiling, not more. This buffer +// is a fixed array (not a growable slice) because mallocBase below caches its +// linear-memory address once; a slice that reallocates on growth would go +// stale and corrupt every pointer handed to the host afterward. Keep this in +// sync with MaxRequestBodyBytes if either changes. +var mallocBuffer [1 * 1024 * 1024]byte // mallocOffset tracks the next free position in mallocBuffer. var mallocOffset int32 diff --git a/wasm_exports.go b/wasm_exports.go index c6b4e44..fe3c631 100644 --- a/wasm_exports.go +++ b/wasm_exports.go @@ -3,22 +3,29 @@ // Package plugin — WASM export layer. // // This file provides the four exported functions that the paca host runtime -// expects: Init, HandleRequest, HandleEvent, Shutdown, plus malloc/free for -// host-managed memory allocation. +// expects: Init, HandleRequest, HandleEvent, Shutdown, plus paca_malloc/ +// paca_free for host-managed memory allocation. package plugin // ── Memory management ───────────────────────────────────────────────────────── +// Exported as paca_malloc/paca_free, not malloc/free: TinyGo's own bundled +// wasi-libc allocator already exports functions literally named "malloc" and +// "free", so exporting under those same names produces a WASM module with +// duplicate export names — invalid per spec, and rejected at load time. The +// host's export lookup (services/api/internal/platform/plugin/runtime.go) +// must use these same names. + // nolint // exported function used by host runtime via WASM interface // -//go:wasmexport malloc +//go:wasmexport paca_malloc func malloc(size int32) int32 { return wasmMalloc(size) } // nolint // exported function used by host runtime via WASM interface // -//go:wasmexport free +//go:wasmexport paca_free func free(_ int32) {} // ── Exported WASM functions ─────────────────────────────────────────────────── From eac8b111c8b95754a5c7c51eed3494a6487804a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=E1=BA=A3i=20Hu=E1=BB=B3nh?= <41873019+pikann@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:05:49 +0700 Subject: [PATCH 2/3] Update README.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f9b5a1..64eaa2b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ since every loaded plugin gets its own independent copy of that runtime on the host, this is a real difference in the host's memory use per plugin, not just download size. -Standard Go 1.21+ also works (`GOARCH=wasm GOOS=wasip1 go build +Standard Go 1.24+ also works (`GOARCH=wasm GOOS=wasip1 go build -buildmode=c-shared -o plugin.wasm main.go`) and is the right fallback if your plugin hits a TinyGo compatibility limitation — TinyGo implements a subset of the stdlib, notably around `reflect`. One TinyGo-specific From 27fd67b6d6ab1391a549d203cc529040146f12af Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 22 Aug 2026 16:12:13 +0000 Subject: [PATCH 3/3] feat: enhance WASM memory management by resetting allocator after request handling --- wasm_backends.go | 7 +++++-- wasm_exports.go | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/wasm_backends.go b/wasm_backends.go index 780b04e..29c7808 100644 --- a/wasm_backends.go +++ b/wasm_backends.go @@ -12,8 +12,11 @@ import ( // ── Memory management ───────────────────────────────────────────────────────── // mallocBuffer is a pre-allocated buffer for host-managed memory allocations. -// The host writes request data into this buffer and reads response data from -// it. Sized to match services/api's DefaultResourceLimits.MaxRequestBodyBytes +// The host writes request data into this buffer, and the response is later +// packed into the same space — never both at once: wasm_exports.go resets +// the allocator between reading the request and allocating the response, so +// each phase gets the buffer's full capacity rather than splitting it. Sized +// to match services/api's DefaultResourceLimits.MaxRequestBodyBytes // (paca/services/api/internal/platform/plugin/runtime.go) — the host already // rejects any payload larger than that before ever attempting to write it // here, so this only needs to cover the same ceiling, not more. This buffer diff --git a/wasm_exports.go b/wasm_exports.go index fe3c631..13ee8e2 100644 --- a/wasm_exports.go +++ b/wasm_exports.go @@ -48,6 +48,12 @@ func HandleRequest(ptr, length int32) int64 { } payload := wasmSlice(ptr, length) result := globalDispatcher.handleRequest(payload) + // handleRequest fully consumes payload via unmarshalJSON before doing + // anything else, so the request bytes are no longer needed by the time + // it returns. Reclaim the whole arena for the response instead of + // stacking the response after the request — otherwise a request near + // the arena's capacity would leave no room to allocate the response. + wasmResetAllocator() return packWASMResult(result) } @@ -63,6 +69,7 @@ func EvaluateCondition(ptr, length int32) int64 { } payload := wasmSlice(ptr, length) result := globalDispatcher.evaluateCondition(payload) + wasmResetAllocator() // see HandleRequest: payload is fully consumed by this point return packWASMResult(result) } @@ -73,6 +80,7 @@ func RunAction(ptr, length int32) int64 { } payload := wasmSlice(ptr, length) result := globalDispatcher.runAction(payload) + wasmResetAllocator() // see HandleRequest: payload is fully consumed by this point return packWASMResult(result) }