You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
Deletecmd/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.
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.
Motivation
The
cmd/wasmevaluation 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=524288linker hack incmd/wasm/targets/*.json). Recursiveencoding/jsondecoding 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 inmain!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
encoding/jsonreflection, generics (evaluation.Evaluate[any]), and the ANTLR-based targeting engine (antlr4-go/antlr/v4vianikunjy/rules) all run under the exact same runtime as the relay proxy / core library — no TinyGo GC/reflect/stdlib divergence.recover()and standard panic semantics.Costs & risks
.wasm) target is an open question — big Go's browser story (GOOS=js) is a different model requiringwasm_exec.jsglue.What would have to change
1. The module (
cmd/wasm)//export evaluate→//go:wasmexport evaluate(requires Go ≥1.24; we're on 1.24.13). Current signatures (*uint32,uint32,uint64) fitwasmexport's allowed types (i32/i64/f32/f64/pointers only — no strings/slices), so they map cleanly; confirm pointer-param handling.-buildmode=c-sharedso the module is a WASI reactor exposing_initialize(called once after instantiation) instead of_start.main()is no longer the entry point.malloc/free/calloc/reallocare TinyGo runtime auto-exports — standard Go does not provide a C-style allocator ABI. Recommended approach to minimize host churn: hand-write and//go:wasmexportamalloc/freepair 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 existingmalloc → write → evaluate → unpack → freehost call sequence. (Alternative: redesign to a guest-owned scratch-buffer protocol — cleaner but far more invasive to hosts.)helpers/wasm.go: revalidate theunsafepointer math and thelastOutputGC-pin against big Go's wasm GC (currently non-moving, but confirm); the packed-uint64return can stay.cmd/wasm/targets/*.jsonand the-z stack-size=524288linker hack — no fixed shadow stack under big Go.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)build-wasi/build-wasmwith, roughly:GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -trimpath -ldflags "-s -w" -o out/bin/gofeatureflag-evaluation.wasi ./....wasm: decide between (a) dropping the nativeGOOS=jsbuild and running the.wasivia a browser WASI polyfill, or (b) maintaining a separatejs/wasmbuild withwasm_exec.js. Today one TinyGo codebase produces both — big Go does not.3. Release pipeline
release-wasm.yml: dropacifani/setup-tinygo@v3.0.0(TinyGo0.40.1); keepsetup-go.release_wasm.shand artifact names (gofeatureflag-evaluation_<version>.{wasi,wasm}) can stay identical, so thewasm-releasespublish +bump-wasm-contribfan-out are unchanged in shape.4. Host providers (coordination cost across 4 repos)
_initializeinstead of_starton instantiate; samemalloc/free/evaluate/memorysequence otherwise. Affected: Python (evaluate_wasm.py_create_slot), Java/Chicory, .NET/wasmtime-dotnet, JS.Decision gate (spike deliverable)
Build a proof-of-concept big-Go binary and measure it against the current TinyGo
0.2.xartifact:.wasi/.wasmsizeinlists, deep ANTLR nesting) now succeed instead of trapping?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_PATHtrap suite (tests/test_wasm_trap_diagnosis.py) to validate the candidate binary.Non-goals
EvaluateInput).References
cmd/wasm/README.md(current ABI + trap contract)//go:wasmexport,-buildmode=c-sharedfor wasip1 reactors