HIP-105: In Process Extension Runtime Standard. Status Final. Hanzo architectural specification.
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.
Hanzo services regularly need to run user-supplied code without spinning up a Knative pod or shelling out to a sidecar:
The naïve options are all bad:
kills the service for every tenant.
milliseconds, pipes are bytes, memory floor is tens of megabytes per tenant.
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.
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.
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 |
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.
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.
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.
| 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 |
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.
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.
| 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) |
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) |
| 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 |
| Runtime added | Binary growth | |---|---:| | native (registry only) | +89 KB | | wazero | +620 KB | | goja | +10.7 MB | | v8go | +36.3 MB (cgo + V8 statics) |
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:
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/v8vm ships but is for benchmarking and experimental use only. Tracked upstream — revisit if v8go ships per-context isolation and resolves the crash.
~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.
for the workloads measured; halving the pool halves the per-module memory floor.
compile-cache miss + linear-memory floor compounds; see Open Questions below for a future test.
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".
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:
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.
Default; no further questions.
(e.g. tenants could be actively malicious)?**
C plugin authors) → wazero. Pool default 4.
lands — see Open Questions). Until then, accept the soft sandbox and use goja (see next item).
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 ctx deadline and resource limits at the host boundary.
.base.js hook from the legacy plugins/jsvmpath? → goja** (back-compat).
production.** v8go v0.9 SIGSEGVs inside libv8 at modest concurrency (~100 invokers). The plugins/v8vm runtime ships but is for benchmarking and experimental use only until upstream ships per-context isolation that resolves the crash.
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 calls Py_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: pyvm in extension manifests. Gate with -tags pyvm at build time; default off.
Python 3.13 free-threading (PEP 703, python3.13t) is detected at runtime via pyvm.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, default BASE_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.
GPU is needed?** → That's HIP-0060 Functions, not this. Wrong HIP.
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).
234× native coldstart eats your tail latency budget. Pre-load at boot and reuse modules.
"wasm is faster". It is 4-10× slower in this benchmark for equivalent CPU-bound work. Wasm wins on sandbox**, not speed.
Per HIP-0014 every Hanzo service ships as a single Go binary. Service logic is native code; extension points are where user code lives.
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: true for goja is honest in the common case; caveated against adversarial guests.)
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.
TerminateExecution is isolate-wide. Because Hanzo serializeson the isolate today, the cancellation target is unambiguous. If/when v8go grows per-context isolation, the cancellation story has to be re-examined.
hanzoai/base, on main):plugins/extruntime/ — interface, manifest, native impl, loaderplugins/gojavm/ — goja adapterplugins/wasmvm/ — wazero (pure Go)plugins/pyvm/ — CPython via pyvm_bridge.{c,h} (cgo)plugins/starkvm/ — Starlarkplugins/v8vm/ — v8go (cgo, -tags v8vm, stub for the default build)plugins/extbench/ — fixtures + benchmark harness 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.
go build ./plugins/... and go test ./plugins/... are clean. The v8vm tag is NOT verified: rogchap.com/v8go v0.9.0 is declared in go.mod but absent from the module cache, so a tagged build cannot reach the compiler without fetching it.
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.
enforce memory isolation; goja and native share the host heap.
v8go v0.9 lacks a per-isolate heap-limit API (V8 default ~768MB-1.5GB applies). Host code MUST set a ctx deadline on every Invoke to protect against runaway CPU.
module relative to the manifest directory and refuses paths containing ...
name must be alphanumeric + dash/underscore; runtime must 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.
emits a WASI command module (_start reads 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 in plugins/extbench/fixtures/wazero-javy/TODO.md.
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.
plugins/jsvm has filesystem watching; the newextension loader does not. Add only if a real consumer asks for it.
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.
~/work/hanzo/base/docs/EXTENSIONS_BENCHMARK.md