Skip to content

(change) Investigate migrating the WASM evaluation module from TinyGo to standard Go (wasip1) #5655

Description

@thomaspoignant

Motivation

The cmd/wasm evaluation module is currently compiled with TinyGo 0.40.1. The root-cause investigation for #5651 showed that TinyGo runs the module on a fixed shadow stack (64KB default, raised to 512KB via a -z stack-size=524288 linker hack in cmd/wasm/targets/*.json). Recursive encoding/json decoding and recursive targeting-query parsing on realistic/pathological flag configs can overflow that stack, trap the instance, and permanently poison it (every later call faults in main!malloc).

We hardened this in TinyGo (depth guards + recover() + the poisoned-instance host contract), and that ships independently. This issue is a separate investigation: standard Go grows goroutine stacks dynamically, so migrating the build from TinyGo to big Go (GOOS=wasip1 GOARCH=wasm) would remove the entire stack-overflow trap class at its source — but the migration is non-trivial and has real costs (much larger binary, higher per-instance memory). We should measure before committing.

This is an investigation/spike with a go/no-go decision gate, not a committed migration.

Potential benefits

  • Eliminates the Python provider WASM malloc crash under high concurrency: TinyGo allocator corruption in fixed 768KB memory #5651 trap trigger. No fixed shadow stack → deep recursion grows the stack instead of trapping. The depth guards become defense-in-depth (DoS mitigation) rather than the only thing between us and a poisoned instance.
  • Full runtime fidelity. encoding/json reflection, generics (evaluation.Evaluate[any]), and the ANTLR-based targeting engine (antlr4-go/antlr/v4 via nikunjy/rules) all run under the exact same runtime as the relay proxy / core library — no TinyGo GC/reflect/stdlib divergence.
  • Full recover() and standard panic semantics.

Costs & risks

  • Binary size grows substantially (order ~10×: TinyGo ~hundreds of KB → big Go multi-MB). The artifact is bundled into the Python/Java/.NET/JS provider packages — package size and cold-start load time need to be measured.
  • Higher per-instance memory (full Go runtime + GC heap). The providers run an instance pool (e.g. 50 slots); memory multiplies across the pool. This is the main viability question.
  • ABI redesign (see below) — ripples into all four host implementations.
  • Browser (.wasm) target is an open question — big Go's browser story (GOOS=js) is a different model requiring wasm_exec.js glue.

What would have to change

1. The module (cmd/wasm)

  • Export directive: //export evaluate//go:wasmexport evaluate (requires Go ≥1.24; we're on 1.24.13). Current signatures (*uint32, uint32, uint64) fit wasmexport's allowed types (i32/i64/f32/f64/pointers only — no strings/slices), so they map cleanly; confirm pointer-param handling.
  • Reactor, not command: build with -buildmode=c-shared so the module is a WASI reactor exposing _initialize (called once after instantiation) instead of _start. main() is no longer the entry point.
  • Allocator (the crux): malloc/free/calloc/realloc are TinyGo runtime auto-exports — standard Go does not provide a C-style allocator ABI. Recommended approach to minimize host churn: hand-write and //go:wasmexport a malloc/free pair backed by a Go-side registry (map[uintptr][]byte) that pins buffers so the GC won't reclaim them and returns pointers into Go-owned linear memory. This preserves the existing malloc → write → evaluate → unpack → free host call sequence. (Alternative: redesign to a guest-owned scratch-buffer protocol — cleaner but far more invasive to hosts.)
  • helpers/wasm.go: revalidate the unsafe pointer math and the lastOutput GC-pin against big Go's wasm GC (currently non-moving, but confirm); the packed-uint64 return can stay.
  • Delete cmd/wasm/targets/*.json and the -z stack-size=524288 linker hack — no fixed shadow stack under big Go.
  • Re-evaluate the depth guards & poisoned-instance contract (guards.go, README.md): stack-overflow traps go away, but genuine traps (OOM) still don't unwind — keep the guards as DoS mitigation and keep (or relax) the "discard a trapped instance" host rule.

2. Build (Makefile)

  • Replace the TinyGo commands in build-wasi/build-wasm with, roughly:
    GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -trimpath -ldflags "-s -w" -o out/bin/gofeatureflag-evaluation.wasi ./...
  • Browser .wasm: decide between (a) dropping the native GOOS=js build and running the .wasi via a browser WASI polyfill, or (b) maintaining a separate js/wasm build with wasm_exec.js. Today one TinyGo codebase produces both — big Go does not.
  • Pin the toolchain (GOTOOLCHAIN / go.mod ≥1.24).

3. Release pipeline

  • release-wasm.yml: drop acifani/setup-tinygo@v3.0.0 (TinyGo 0.40.1); keep setup-go. release_wasm.sh and artifact names (gofeatureflag-evaluation_<version>.{wasi,wasm}) can stay identical, so the wasm-releases publish + bump-wasm-contrib fan-out are unchanged in shape.

4. Host providers (coordination cost across 4 repos)

  • If the ABI shape is preserved, each host needs a small change: call _initialize instead of _start on instantiate; same malloc/free/evaluate/memory sequence otherwise. Affected: Python (evaluate_wasm.py _create_slot), Java/Chicory, .NET/wasmtime-dotnet, JS.
  • Verify each runtime supports wasip1 reactor modules — wasmtime ✅, wasmtime-dotnet ✅, Chicory (Java) needs confirming, JS runtime needs confirming.
  • Account for the larger artifact in each package (size limits, load time).

Decision gate (spike deliverable)

Build a proof-of-concept big-Go binary and measure it against the current TinyGo 0.2.x artifact:

Metric Why it matters
.wasi / .wasm size bundled in provider packages
Cold-start / instantiation time pool churn, provider startup
Per-instance linear memory + total across a 50-slot pool main viability question
Eval latency (p50/p99), representative + pathological payloads perf regression check
Do the #5651 pathological payloads (deep nesting, large in lists, deep ANTLR nesting) now succeed instead of trapping? confirms the core benefit

Go / no-go: proceed only if pooled-memory and binary size are within acceptable bounds and the stack-overflow trap class is demonstrably eliminated. Reuse the existing GOFF_TEST_WASI_PATH trap suite (tests/test_wasm_trap_diagnosis.py) to validate the candidate binary.

Non-goals

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    changeThis is a change in the code that should not affect the userspriority:P3Longer term priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions