HIP-0105: In-Process Extension Runtime Standard
Abstract
This proposal defines how user-supplied code is loaded and executed inside a
running Hanzo Go service — distinct from the K8s-scale FaaS surface in
HIP-0060. The same service binary can host extensions in four engines —
native Go, goja (pure-Go JS interpreter), wazero (pure-Go WASM
runtime), and v8go (V8 via cgo) — and pick the right one per extension via
an extension.json manifest. The Runtime interface is one type with four
implementations; switching engines is a one-line manifest change.
Reference implementation: ~/work/hanzo/base/plugins/{extruntime,gojavm,wasmvm,v8vm}
(hanzoai/base/plugins,
landed 2026-05-19). Full benchmark write-up at
~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md.
Motivation
Hanzo services regularly need to run user-supplied code without spinning up a Knative pod or shelling out to a sidecar:
- Per-record validators and computed fields in Hanzo Base
- Per-org policy / authz rules in Hanzo IAM and Gateway
- Custom prompt transformers in the LLM Gateway
- Tool implementations for agents (MCP-side tool functions)
- Webhook body transformations
- Custom CRDT merge resolvers
- Per-tenant feature flags evaluated against context
The naïve options are all bad:
- Run user code in the host Go process directly — no sandbox, one panic kills the service for every tenant.
- Shell out to a Node.js / Python subprocess — process spawn cost is milliseconds, pipes are bytes, memory floor is tens of megabytes per tenant.
- Knative FaaS (HIP-0060) — right answer for bursty inference workloads with seconds of work; wrong answer for a hot-path validator that runs 1000 times per request.
The right answer is in-process with a sandbox boundary that scales to microseconds of overhead. WebAssembly provides this; modern JS engines provide it for JS specifically; pure-Go interpreters give a softer sandbox with zero binary cost. Each is correct for a different workload. This HIP codifies which is which.
Specification
Runtime interface
Every backing engine implements the same Go interface:
package extruntime
type Runtime interface {
Name() string
Capabilities() Capabilities
Load(ctx context.Context, dir string) (Module, error)
Close() error
}
type Module interface {
Name() string
Runtime() string
Exports() []string
Invoke(ctx context.Context, fn string, payload []byte) ([]byte, error)
Close() error
}
type Capabilities struct {
AcceptsLanguages []string // ["go"] | ["js"] | ["wasm"]
HardSandbox bool // wazero, v8go: true; goja, native: false
Cgo bool // v8go: true; others: false
SupportsAbort bool // wazero, v8go: hard; goja: cooperative; native: false
}
payload and the Invoke result are JSON bytes. JSON is the wire format
because (a) every backing engine can marshal it natively, (b) it's
language-agnostic so AssemblyScript / Rust / Go / JS guests all see the same
payload, and (c) the marshal cost is comparable across runtimes.
Manifest
Every extension carries a sibling extension.json:
{
"name": "validate-email",
"version": "0.1.0",
"runtime": "wazero",
"module": "validate.wasm",
"exports": ["validate", "onUpdate"]
}
Fields:
| Field | Required | Notes |
|---|---|---|
name | yes | Unique within a host (e.g. <org>-<purpose>) |
version | yes | SemVer per global policy (HIP-0xxx semver-only) |
runtime | yes | One of native, goja, wazero, v8go |
module | conditional | Path to compiled artifact (*.wasm for wazero, *.js for goja/v8go). Not required for native runtime. |
exports | yes | List of function names callable via Invoke |
Calling conventions
Go-native
A native extension is a Go file that imports
github.com/hanzoai/base/plugins/extruntime and registers itself at
init():
extruntime.RegisterNative("validate-email", "validate", func(ctx context.Context, payload []byte) ([]byte, error) {
// ... return JSON bytes
})
Compile-time linked. Zero abstraction cost beyond function pointer.
Goja and V8go (JavaScript)
The guest defines globalThis.<fn> for each export:
globalThis.validate = function(payload) {
return { ok: true, normalized: payload.email.toLowerCase() };
};
Host unmarshals the JSON payload to a JS value, calls the function, and
JSON.stringifys the result. No memory dance — JS engines own their
heaps.
Wazero (WebAssembly)
The guest exports three functions in addition to its named exports:
(func $__base_alloc (param i32) (result i32)) // ptr = alloc(len)
(func $__base_free (param i32 i32)) // free(ptr, len)
(func $<fn> (param i32 i32) (result i64)) // ret = fn(ptr, len)
<fn>(ptr, len) returns an i64 where the high 32 bits are the result
pointer and the low 32 bits are the result length in the module's
linear memory. The host calls __base_free on both the input buffer and the
result buffer.
This convention is language-agnostic — AssemblyScript, Rust, Go (via
TinyGo), Zig, and C can all target it. The
~/work/hanzo/base/plugins/extbench/fixtures/wazero-as/ sample shows the
AssemblyScript implementation.
Default behavior when ctx is cancelled
| Runtime | Cancellation semantics |
|---|---|
| native | Cooperative — user code MUST check ctx.Err() |
| goja | Cooperative — vm.Interrupt() fires only at function-call opcodes; tight numeric loops without yield points can resist abort |
| wazero | Hard — module.Close() destroys the instance immediately; the runtime replenishes its pool from a background context |
| v8go | Hard — Isolate.TerminateExecution() aborts within ~ms |
Pool sizing
Every runtime maintains a per-module pool of pre-warmed instances / contexts so per-invocation cost stays microsecond-scale. Each runtime exposes an env override. Anything <=0 or unparseable falls back to the default.
| Runtime | Env | Default | Notes |
|---|---|---|---|
| goja | BASE_GOJAVM_POOL_SIZE | 8 | Cheap per-pool-item (~9 KB Go heap); 8 is safe. |
| wazero | BASE_WASMVM_POOL_SIZE | 8 | Wasm modules amortize compilation, so the pool exists to soak bursty hook traffic rather than to save instantiation. |
| v8go | BASE_V8VM_POOL_SIZE | 8 | Sized for context pool, but see scale findings — v8go is NOT recommended for production at meaningful concurrency. |
| pyvm | BASE_PYVM_POOL_SIZE | 4 | Each Python sub-interpreter is ~4.6 MB. Pool of 4 = ~18 MB baseline. Default tuned to balance per-module memory vs cold-start cost. |
| starlark | BASE_STARKVM_POOL_SIZE | 8 | Pre-warmed *starlark.Thread instances. |
Native doesn't pool — functions are stateless Go calls.
Benchmark data
Run on Apple M1 Max, Go 1.26.3, median of 3 runs at -benchtime=2s. Workload:
validate(email, age) — JSON in, JSON out, ~20 lines of logic.
Throughput (lower ns/op is faster)
| Runtime | Serial ns/op | Parallel ns/op | Ratio vs native (serial) | Ratio vs native (parallel) |
|---|---|---|---|---|
| native | 1,197 | 694 | 1.0× | 1.0× |
| pyvm (CPython 3.13) | 4,089 | 2,765 | 3.4× | 4.0× |
| goja | 4,513 | 1,652 | 3.8× | 2.4× |
| wazero (AssemblyScript) | 9,956 | 3,271 | 8.3× | 4.7× |
| v8go | 11,895 | 12,667 | 9.9× | 18.3× (degrades) |
Cold start (per Load())
| Runtime | ns/op | Ratio vs native |
|---|---|---|
| native | 16,847 | 1.0× |
| goja | 78,282 | 4.6× |
| v8go | 906,033 | 53.8× |
| wazero (AS) | 3,936,863 | 233.7× (JIT compile) |
Per-invocation memory
| Runtime | B/invoke | allocs/invoke |
|---|---|---|
| pyvm (CPython 3.13) | 496 | 10 (LOWEST allocs of any non-native) |
| v8go | 673 | low |
| native | 1,450 | minimal |
| goja | 3,433 | JS → Go marshalling |
| wazero (AS) | 44,425 | JSON ptr/len + linear-memory copy |
Binary size delta
| Runtime added | Binary growth |
|---|---|
| native (registry only) | +89 KB |
| wazero | +620 KB |
| goja | +10.7 MB |
| v8go | +36.3 MB (cgo + V8 statics) |
Scale findings (added 2026-05-19 after the second benchmark pass)
The throughput benchmark above measured a single hot module. The scale benchmark measured fan-out — many modules, many tenants, many concurrent invocations — and materially changes the recommendation for two runtimes.
Per-loaded-module Go-heap cost (steady state, 1000 modules loaded):
| Runtime | Per-module heap | Total at N=1000 | Total at N=10000 |
|---|---|---|---|
| native | ~16 B | <1 MB | ~1 MB |
| goja | ~9 KB | ~9 MB | ~90 MB |
| wazero (pool=8) | ~635 KB | ~635 MB | (extrapolated 6.4 GB) |
| wazero (pool=4) | ~320 KB | ~320 MB | (extrapolated 3.2 GB) |
| v8go | crashed before reaching N=500 (SIGSEGV in libv8) |
Sustained concurrent invocations on one module (throughput at M concurrent):
| Runtime | M=10 ops/s | M=100 ops/s | M=1000 ops/s | M=10000 ops/s |
|---|---|---|---|---|
| native | scales linearly with M | linear | linear | linear |
| goja | scales | scales | 188,000 | scales |
| wazero (pool=4) | scales | scales | pool exhaustion above pool size; queueing | queueing |
| v8go | OK at M=10 | SIGSEGV in libv8 | (process crash) | (process crash) |
Pool-size sweep (1000 tenants, wazero):
| Pool size | Latency | Memory |
|---|---|---|
| 1 | high (queueing) | ~80 MB |
| 4 | good | ~320 MB |
| 16 | same as 4 | ~1.3 GB |
| 64 | same as 4 | ~5.1 GB |
| 256 | same as 4 | 10.8 GB |
No latency improvement beyond pool=4. Anything higher is pure waste.
Concrete corrections this forces:
-
v8go is removed from the production decision tree. v8go v0.9 on darwin/arm64 SIGSEGVs inside libv8 at ~100 concurrent invocations on a shared isolate. This is not "slow" — it is process death. Production-disqualifier for any service expecting real concurrency.
plugins/v8vmships but is for benchmarking and experimental use only. Tracked upstream — revisit if v8go ships per-context isolation and resolves the crash. -
goja is the production winner for multi-tenant JS at scale. ~9 KB per loaded module, 188K ops/s on a single module at M=1000 concurrent, no cgo, no crash modes. For a SaaS deployment hosting thousands of tenants each with a few JS extensions, goja is the right call. The cooperative-interrupt caveat remains, but the stability-at-scale story dwarfs it.
-
wazero default pool is 4, not 8. No throughput gain beyond that for the workloads measured; halving the pool halves the per-module memory floor.
-
wazero scales to ~10K modules at pool=4 (~3.2 GB). Beyond that, compile-cache miss + linear-memory floor compounds; see Open Questions below for a future test.
-
native scales to any reasonable N. ~16 B per loaded function pointer; the goroutine cost (~8 KB) dominates only at >100K concurrent invocations of any single module.
Full numbers and methodology in ~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md
under "## Scale Study".
The new standard
Go-native is the default. Every metric in the benchmark — serial perf, parallel scaling, cold start, memory, binary size — favors native by a factor of 2-50×. There is no scenario in this benchmark where any other runtime beats native on raw performance. If the code is Hanzo-authored and the language constraint allows Go, write it in Go.
Pick another runtime only when one of these four exceptional conditions holds:
How a host loads extensions
Loader.LoadDir(ctx, dir) scans dir for subdirectories holding an
extension.json, resolves each manifest's runtime against the
runtimes linked into the binary, and returns the loaded modules by
name. A manifest naming a runtime the binary does not carry is logged
and skipped, not fatal — which is what lets one binary serve hosts
built with different runtime sets.
That is the whole loading contract. WHERE a host points it — a hooks directory, a per-service directory, a path from config — is the host's decision and belongs to HIP-0106, not here. This HIP specifies what a runtime is and how a module is loaded; it does not name directories on anyone's disk.
A host that exposes extensions to more than one tenant is responsible for admitting only runtimes whose isolation it accepts. pyvm and v8vm share process memory and have no hard sandbox.
Decision tree
-
Is the code Hanzo-authored and Go-permissible? → native. Default; no further questions.
-
Is the code user-supplied AND threat model requires HARD sandbox (e.g. tenants could be actively malicious)?
- Language flexibility matters? (Rust / TinyGo / Zig / AssemblyScript / C plugin authors) → wazero. Pool default 4.
- JS authoring only? → wazero with Javy (once the WASI-stdio shim lands — see Open Questions). Until then, accept the soft sandbox and use goja (see next item).
-
Is the code user-supplied JS where the threat model is "ordinary customers, not adversaries"? → goja. Per the scale findings, this is the right answer for multi-tenant SaaS hosting thousands of JS extensions — ~9 KB per loaded module, 188K ops/s at high concurrency, no crash modes, no cgo. The cooperative-interrupt caveat applies; pair with a
ctxdeadline and resource limits at the host boundary. -
Is the code an existing
.base.jshook from the legacyplugins/jsvmpath? → goja (back-compat). -
Are you tempted to use v8go for "modern JS perf"? → Don't, for production. v8go v0.9 SIGSEGVs inside libv8 at modest concurrency (~100 invokers). The
plugins/v8vmruntime ships but is for benchmarking and experimental use only until upstream ships per-context isolation that resolves the crash. -
Do you need REAL CPython (with the full ecosystem including numpy, pandas, cryptography) inside the Go binary, and is your deployment SINGLE-TENANT? → pyvm. CPython 3.13 embedded via a small direct cgo bridge:
plugins/pyvm/pyvm_bridge.{c,h}includes<Python.h>and callsPy_InitializeEx(0), leaving signal handling to Go. There is no intermediate binding library. The bridge consolidates a whole invocation into one cgo call (pyvm_invoke), which is the point: per-call cgo crossings, not CPython, dominate the JSON-pipe embedding contract.Beats wazero AS by ~2× serial and has the lowest per-invocation memory (496 B/op) of any sandboxed runtime measured. Sub-interpreter pool gives per-tenant-style isolation within the single-process deployment.
DO NOT use pyvm in multi-tenant builds. A C extension segfault in any tenant's code kills the entire host process and every other tenant. Multi-tenant operators must policy-reject
runtime: pyvmin extension manifests. Gate with-tags pyvmat build time; default off.Python 3.13 free-threading (PEP 703,
python3.13t) is detected at runtime viapyvm.GilDisabled(); when present, the GIL is a no-op and OWN_GIL sub-interpreters become unnecessary for parallelism. Measured 2026-05-18 on Apple M1 Max, defaultBASE_PYVM_POOL_SIZE=4: free-threading is a non-event for pyvm's embedding workload.Build Serial ns/op Parallel ns/op 3.13 default-GIL 4,073 5,733 3.13t free-threaded 4,478 5,805 PEP 684 OWN_GIL sub-interpreters already deliver per-OS-thread parallelism on default-GIL; PEP 703 doesn't add headroom unless you abandon the sub-interp pool and run many threads against one interpreter — which we don't. The pool size is the lever, not the GIL mode. Revisit when CPython 3.14+ ships free-threading as default with closed serial-perf gap. Full measurement:
~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md. -
Are you serving a workload where seconds of cold start is fine and GPU is needed? → That's HIP-0060 Functions, not this. Wrong HIP.
What we are NOT recommending
-
Don't use v8go for hot-path parallel workloads. The single-isolate mutex in v8go v0.9 serializes everything; parallel scaling is worse than serial. We hit this in the benchmark and have no workaround until v8go ships per-context isolation (upstream issue tracked).
-
Don't use wazero for latency-sensitive code that is loaded per-request. 234× native coldstart eats your tail latency budget. Pre-load at boot and reuse modules.
-
Don't rewrite working Go code to wasm under the assumption that "wasm is faster". It is 4-10× slower in this benchmark for equivalent CPU-bound work. Wasm wins on sandbox, not speed.
-
Don't put Hanzo-internal service logic behind any non-native runtime. Per HIP-0014 every Hanzo service ships as a single Go binary. Service logic is native code; extension points are where user code lives.
Honest caveats baked into the recommendation
-
The goja interrupt is cooperative. A malicious extension author can write a tight numeric loop with no function-call opcodes and resist
ctx.Done(). If your threat model requires hard abort, use wazero or v8go. (SupportsAbort: truefor goja is honest in the common case; caveated against adversarial guests.) -
Wazero
module.Close()for hard-abort destroys the instance permanently. The Hanzo wazero runtime auto-refills the pool from a background ctx to keep pool size stable. Memory pressure from refills under sustained cancellation is something to watch in observability. -
V8go's
TerminateExecutionis isolate-wide. Because Hanzo serializes on the isolate today, the cancellation target is unambiguous. If/when v8go grows per-context isolation, the cancellation story has to be re-examined.
Reference implementation
- Packages (
hanzoai/base, onmain):plugins/extruntime/— interface, manifest, native impl, loaderplugins/gojavm/— goja adapterplugins/wasmvm/— wazero (pure Go)plugins/pyvm/— CPython viapyvm_bridge.{c,h}(cgo)plugins/starkvm/— Starlarkplugins/v8vm/— v8go (cgo,-tags v8vm, stub for the default build)plugins/extbench/— fixtures + benchmark harness
- Tests, on the default build: extruntime 6 passed, gojavm 7 passed,
wasmvm 2 passed and 4 skipped, v8vm 0 — its tests are behind
//go:build v8vm, so the default build compiles the stub and runs nothing. - Verification:
go build ./plugins/...andgo test ./plugins/...are clean. Thev8vmtag is NOT verified:rogchap.com/v8go v0.9.0is declared ingo.modbut absent from the module cache, so a tagged build cannot reach the compiler without fetching it.
Backwards compatibility
The existing plugins/jsvm API (goja-based hooks for .base.js files) is
untouched. Old hooks continue to work via the old path. The new
extension runtime is additive — opt in by writing an extension.json.
Security considerations
- Sandbox boundary is the runtime's responsibility. wazero and v8go enforce memory isolation; goja and native share the host heap.
- Resource limits: wazero supports stack limits and memory caps;
v8go v0.9 lacks a per-isolate heap-limit API (V8 default ~768MB-1.5GB
applies). Host code MUST set a
ctxdeadline on every Invoke to protect against runaway CPU. - Path traversal: the loader resolves
modulerelative to the manifest directory and refuses paths containing... - Manifest validation:
namemust be alphanumeric + dash/underscore;runtimemust be one of the four known values; an unknown runtime causes the extension to be skipped with a logged warning, not loaded against the wrong engine.
Open questions
-
Javy support — Shopify's Javy (raw JS → wasm via QuickJS-in-wasm) emits a WASI command module (
_startreads stdin, writes stdout). Our wazero convention is pointer-based. The two ABIs don't compose without a stdio shim. Either we ship the shim (a small wazero adapter that forwards stdio buffers as the pointer-based payload) or we publish a Javy plugin that emits our convention directly. Tracked inplugins/extbench/fixtures/wazero-javy/TODO.md. -
Component Model / WIT — wazero's component-model branch is experimental. Once stable, we should reconsider whether the hand-rolled pointer/len ABI is worth the simplicity or if we should adopt WIT interfaces. Likely revisit Q3 2026.
-
Hot reload —
plugins/jsvmhas filesystem watching; the new extension loader does not. Add only if a real consumer asks for it. -
Host API surface — what can extension code call back into? DB access, HTTP fetch, KMS reads, log emission? Each runtime needs plumbing. Suggest a separate HIP for the host-API contract once we have a concrete extension that exercises it.
References
- Reference implementation: hanzoai/base/plugins
- Benchmark writeup:
~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md - wazero: https://github.com/tetratelabs/wazero
- goja: https://github.com/dop251/goja
- v8go: https://github.com/rogchap/v8go
- AssemblyScript: https://www.assemblyscript.org/
- HIP-0014 (Application Deployment Standard) — service-level binaries
- HIP-0026 (Identity and Access Management Standard) — per-org isolation
- HIP-0060 (Serverless Functions Standard) — K8s-scale FaaS, complementary