Skip to content

feat: optimize plugin ram usage - #430

Open
pikann wants to merge 1 commit into
masterfrom
feature/optimize-plugin-ram-usage
Open

feat: optimize plugin ram usage#430
pikann wants to merge 1 commit into
masterfrom
feature/optimize-plugin-ram-usage

Conversation

@pikann

@pikann pikann commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Cuts plugin memory overhead by switching the recommended/default plugin build toolchain from standard Go to TinyGo, and tightens the request body limit to match.

  • Default to TinyGo for building plugin WASM binaries. Standard Go statically links its full runtime (GC, goroutine scheduler, reflection) into every plugin binary, so each loaded plugin carries its own independent copy of that runtime in host memory — tens of MiB per plugin. TinyGo avoids this, cutting per-plugin memory to a few MiB. install-local-plugin.sh now defaults to TinyGo (--go-toolchain/GO_TOOLCHAIN lets you opt back into standard Go for plugins that hit a TinyGo stdlib limitation, e.g. reflect-heavy code like html/template).
  • Require -buildmode=c-shared for TinyGo builds. Without it, TinyGo skips wiring up the WASI reactor entry point (_initialize), and every call into the plugin panics at runtime with "//go:wasmexport function called before runtime initialization". Docs and scripts are updated accordingly.
  • Rename the SDK's exported allocator from malloc to paca_malloc. TinyGo-built plugins already export their own malloc/free from wasi-libc, which collided with plugin-sdk-go's export of the same name. runtime.go's writeToMemory and all WASM test fixtures (cacheplugin, poisonplugin, echoplugin) are updated to use paca_malloc.
  • Lower PLUGINS_MAX_REQUEST_BODY_BYTES default from 10 MiB to 1 MiB, matching plugin-sdk-go's mallocBuffer size, to avoid over-allocating per-call buffers.
  • Documented the //go:wasmimport host-function calling convention difference: TinyGo requires calling these by name directly rather than passing them as function values (e.g. via plugin.CallHostFunction), since it doesn't support taking their address.

Why

Every loaded plugin previously paid for its own copy of the full Go runtime, which becomes expensive in aggregate as more plugins are installed. Building with TinyGo by default (with standard Go as an explicit opt-out) reduces per-plugin host memory footprint significantly.

Test plan

  • go test ./services/api/internal/config/... (default MaxRequestBodyBytes updated to 1 MiB)
  • go test ./services/api/internal/platform/plugin/... (test fixtures rebuilt with paca_malloc export)
  • go test ./services/api/test/e2e/...
  • Manually build a plugin with tinygo build -target=wasip1 -buildmode=c-shared and confirm it loads and responds correctly

@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces plugin memory usage by making TinyGo the preferred local build toolchain, renaming the host-facing allocator export, and lowering the default plugin payload limit.

  • Adds TinyGo and standard-Go toolchain selection to the local plugin installer.
  • Updates plugin build documentation and WASM test fixtures for paca_malloc.
  • Reduces the default plugin request/event payload limit from 10 MiB to 1 MiB.
  • Introduces an allocator ABI compatibility break and an inconsistent effective HTTP body limit.

Confidence Score: 3/5

The PR should not merge until compatibility with existing plugin allocator exports and the effective HTTP payload-limit mismatch are addressed.

Existing WASM plugins can load but fail on first memory write after the allocator rename, while near-limit HTTP bodies accepted by the edge can be rejected later as internal plugin errors after JSON encoding.

Files Needing Attention: services/api/internal/platform/plugin/runtime.go, services/api/internal/config/load.go, scripts/README.md

Important Files Changed

Filename Overview
services/api/internal/platform/plugin/runtime.go Renames the allocator ABI and lowers the envelope/event limit; lacks compatibility with existing malloc artifacts and rejects some bodies already accepted by the HTTP edge.
services/api/internal/config/load.go Changes the operational default payload limit to 1 MiB, exposing the encoded-envelope limit mismatch.
scripts/install-local-plugin.sh Adds validated TinyGo/Go selection and a clear fallback, but makes TinyGo the default prerequisite.
scripts/README.md Describes TinyGo in the workflow but leaves the requirements section inconsistent with the new default.
docs/plugins/developer-guide.md Documents TinyGo reactor builds, allocator considerations, and standard-Go fallback.
services/api/internal/platform/plugin/testdata/poisonplugin/main.go Updates the test fixture allocator export to match the new runtime ABI.

Sequence Diagram

sequenceDiagram
  participant Client
  participant HTTP as Plugin HTTP handler
  participant Runtime
  participant WASM as Plugin WASM
  Client->>HTTP: "Raw body <= 1 MiB"
  HTTP->>HTTP: "Marshal request envelope<br/>(body becomes base64)"
  HTTP->>Runtime: "HandleRequest(envelope > 1 MiB)"
  Runtime-->>HTTP: Payload exceeds runtime limit
  HTTP-->>Client: 500 plugin execution error
Loading

Comments Outside Diff (1)

  1. scripts/README.md, line 160 (link)

    P2 List TinyGo as required

    The installer now defaults to TinyGo and exits when it is unavailable, but this requirements section still names Go as the backend compiler. Developers following the documented setup therefore encounter a missing-tool failure on the default installation command.

Reviews (1): Last reviewed commit: "feat: optimize plugin ram usage" | Re-trigger Greptile

Comment on lines +2203 to 2206
malloc := m.ExportedFunction("paca_malloc")
if malloc == nil {
return nil, fmt.Errorf("plugin: malloc not exported")
return nil, fmt.Errorf("plugin: paca_malloc not exported")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Preserve the allocator ABI fallback

When an installed plugin was built against the previous allocator ABI, resolving only paca_malloc lets it load but causes every request, event, or host-result memory write to fail with plugin: paca_malloc not exported until the artifact is rebuilt.

Suggested change
malloc := m.ExportedFunction("paca_malloc")
if malloc == nil {
return nil, fmt.Errorf("plugin: malloc not exported")
return nil, fmt.Errorf("plugin: paca_malloc not exported")
}
malloc := m.ExportedFunction("paca_malloc")
if malloc == nil {
// Preserve compatibility with plugins built against the previous allocator ABI.
malloc = m.ExportedFunction("malloc")
}
if malloc == nil {
return nil, fmt.Errorf("plugin: paca_malloc or malloc not exported")
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

MaxMemoryPages: 1024, // 64 MiB
MaxRequestBodyBytes: 10 * 1024 * 1024, // 10 MiB
MaxMemoryPages: 1024, // 64 MiB
MaxRequestBodyBytes: 1 * 1024 * 1024, // 1 MiB — keep in sync with plugin-sdk-go's mallocBuffer size

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Separate raw and envelope limits

When a raw plugin request is below 1 MiB but its JSON envelope exceeds 1 MiB after the []byte body is base64-encoded, the HTTP boundary accepts it and callExport rejects it as an internal plugin error. This reduces the effective raw-body ceiling to roughly 750 KiB and returns a 500 instead of a consistent payload-too-large response.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two things worth addressing before merge: a new allocator-contract bug in writeToMemory, and a backward-compatibility break for already-installed plugin binaries.

Reviewed changes

This review covers the single commit e89cecc (13 files, +103/−20), which reframes plugin builds around TinyGo and tightens the host's memory accounting:

  • TinyGo as the default plugin toolchaindeveloper-guide.md / sdk-reference.md and the scripts docs now prescribe tinygo build -target=wasip1 -buildmode=c-shared, and install-local-plugin.sh adds a --go-toolchain/GO_TOOLCHAIN selector (default tinygo) with toolchain-presence validation.
  • Host allocator export renamed mallocpaca_mallocwriteToMemory now requires the plugin to export paca_malloc (the old name collides with the malloc/free TinyGo's wasi-libc bundles), and the cacheplugin/poisonplugin/echoplugin fixtures were renamed in lockstep; this matches plugin-sdk-go master, which I verified exports paca_malloc/paca_free over a fixed 1 MiB mallocBuffer arena.
  • Lowered default request-body ceilingPLUGINS_MAX_REQUEST_BODY_BYTES drops 10 MiB → 1 MiB in load.go, .env.example, and the defaults test, sized to match the SDK's 1 MiB arena.

⚠️ Existing plugin binaries break when this deploys

writeToMemory now requires a paca_malloc export and no longer falls back to the plugin's malloc. Every already-installed plugin binary compiled against the previous SDK exports only malloc, so after this ships, every host function that returns data into plugin memory (cache_get, storage_get, db_query2's error path, db_exec) fails inside writeToMemory — often silently, since several callers ignore the returned error (runtime.go:801, and the known storage_get/cache_get panic-prone pattern). The PR updates the SDK contract, but ships no migration story: no minimum-core-version gate (the mechanism the repo already has), no fallback read path, and no rebuild-required note.

Technical details
# Preserve compatibility with already-compiled plugin binaries

## Affected sites
- services/api/internal/platform/plugin/runtime.go:2203-2205 — the export lookup switched from "malloc" to "paca_malloc" with no fallback

## Required outcome
- Deploying this API must not silently break plugins that were compiled before the SDK's paca_malloc rename.

## Suggested approach
- In `writeToMemory`, resolve `paca_malloc`, and if absent fall back to `malloc` (old-SDK binaries keep working because their own `wasmSlice` resolves pointers relative to their own arena; new-SDK binaries use the primary path). Confirm plugin-sdk-go's change is tagged/released (not just `master`) so plugin authors building against the new docs pin a stable version. Consider bumping the core-version check so genuinely old plugins fail loudly at load rather than via ignored `writeToMemory` errors.

ℹ️ 1 MiB default not propagated to production example and docs

load.go and services/api/.env.example now default PLUGINS_MAX_REQUEST_BODY_BYTES to 1048576, but deploy/.env.production.example:151 still pins 10485760 and docs/plugins/backend-plugin-system.md:282 still documents 10 MiB as default — so production installs following the example keep the old 10 MiB cap (the RAM win this PR targets won't materialize), and the docs table is now wrong. Align the two files with whichever value is intended; if the prod example stays at 10 MiB deliberately, the doc table still needs correcting.

ℹ️ Nitpicks

  • scripts/install-local-plugin.sh:87 — the new GO_TOOLCHAIN variable collides with Go's own toolchain-selector env var (Go 1.21+ uses GO_TOOLCHAIN=auto|local|go1.x.y); a user with that set will be rejected by the new validation at line 160 with a confusing error. PLUGIN_GO_TOOLCHAIN would avoid the collision.
  • The new docs claim "Standard Go 1.21+", but plugin-sdk-go's README says standard Go 1.24+ works; the version claim should match the SDK so users don't pin a Go release that won't build.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

malloc := m.ExportedFunction("paca_malloc")
if malloc == nil {
return nil, fmt.Errorf("plugin: malloc not exported")
return nil, fmt.Errorf("plugin: paca_malloc not exported")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the plugin's allocator can't fit the payload, plugin-sdk-go's wasmMalloc returns 0. writeToMemory doesn't check that before m.Memory().Write(uint32(ptr), data), so the payload is written into linear memory at offset 0 — corrupting the module's low memory (including the SDK's cached mallocBase global) — and it returns [0, len], which the SDK's wasmSlice maps to nil. The fixed 1 MiB arena makes this reachable in the real SDK path (a near-1 MiB request body followed by any host result written into the same arena), whereas the previous allocator would have grown memory.

Technical details
# writeToMemory must reject a 0 pointer from paca_malloc

## Affected sites
- services/api/internal/platform/plugin/runtime.go:2207-2215 — `results[0]` is never checked for 0 before `m.Memory().Write(uint32(ptr), data)` writes guest memory at offset 0 and the `[0, len]` pair is returned

## Required outcome
- When the guest allocator cannot fit the payload, `writeToMemory` must return an error instead of writing to linear-memory offset 0 and surfacing a nil-mapped pointer.

## Suggested approach
- In `writeToMemory`, check `results[0] == 0` after the call and return `fmt.Errorf("plugin: malloc returned 0 (buffer exhausted)")` before writing. Callers that already ignore the error (`db_exec`, `storage_get`, `cache_get`) degrade to their existing empty-result paths instead of corrupting memory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant