mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
* Support the wasm SIMD proposal with native asm lowering (arm64/amd64) One revision spanning the SIMD (0xfd v128) proposal end to end: SSA representation, lowering, scalar reference helpers, and a gcasm asm backend that turns the hot paths into native SIMD — measured on a full llama.cpp build transpiled to pure Go, which this makes run at V8-class speed (prompt eval beats V8 on arm64). Frontend and SSA: - v128 values are [2]uint64 pairs in little-endian lane order; pure ops lower to CSE-able helper calls, memory ops keep statement order, v128.const/shuffle ride as [2]uint64 aux. The full scalar helper set is the reference semantics (saturation, NaN propagation, round-to-even, swizzle OOB, shift masking). - Exception handling lowers to plain branches instead of panic/recover — a check-and-branch after each may-raise call, a per-try dispatch block running the catch-clause chain — covering the C++-shaped patterns (multi-clause catch, delegate targets, rethrow depths) without runtime unwinding, so EH bodies stay eligible for the asm backend. This replaces the closure/recover lowering that #39 extended; its tests are retained unchanged. - Giant functions outline into tractable units (whole loops included, packed u64 boundaries for v128 values), keeping gc compile times and register pressure sane on llama.cpp-sized modules. gcasm backend: - SIMD helper calls splice inline as native NEON/SSE instead of marshalled calls; v128 SSA values travel register-to-register in pair form. Call-tree windows fuse into single splices with region invariant hoisting, bounds-check coalescing, store sinks and scalar-chain internalization; whole countdown loops fuse with an in-splice unroll lane. - Superinstruction selection inside fused regions: 8-bit dot patterns (SMULL+SADALP), SDOT for full 16-byte dots, FMUL by element, scale-lookup addressing, inlined integer division, the software fp32->fp16 idiom lowered to FCVT (both the table-gather and the packed store form), and ggml q8_0 row/column pairing via the traits nrows patch with an SMMLA 2x2 tile kernel. - Runtime CPU-feature dispatch gates spliced bodies (dotprod on arm64, x86-64-v2 SSE on amd64); WASM2GO_FAST_MATH opts into raw-SDOT/FMLA/dual-accumulator forms that trade wasm bit-exactness for native-build-style rounding, validated by token-level equivalence against native llama.cpp. - Guard-page linear memory eliminates per-access bounds checks; jump tables replace the compare tree for br_table. Tooling and CI: - WASM2GO_F16_TABLE asserts the address of ggml's runtime-built f16 table; the transpiler warns when the asserted address matches no gather site (the address moves with every data-layout shift, and a stale assertion silently costs ~7% decode throughput). - The coverage gate runs with -count=1: a cached package result merges incomplete coverage data into the -coverpkg profile, deflating the total by 10+ points depending on what the runner's restored cache contained — the gate must never consume cached results. - Coverage threshold moves to 83% for the enlarged tree; transpile output is deterministic and outline rejections are explained in the build log. Co-Authored-By: Claude Fable 5 <[email protected]> * Replace environment-variable switches with typed Options; drop debug code Configuration now flows through one channel: transpile.Options gains OutlineMinValues, SIMDUnroll, FuseLoops, FuseLoopUnroll, F16TableAddr and FastMath (with matching CLI flags), the gcasm backend receives them as a Config threaded through ModuleOffsets, and no code path branches on an environment variable anymore. Integrator entry points (the wasmify protoc plugin, the CLI) convert their external configuration into these options exactly once. Deleted outright, per the no-env rule and because they were diagnostics rather than build modes: - every WASM2GO_NO_* kill switch and bisection knob (SSA pass disabling, fallback ranges, fuse-window caps, outline limits, inliner windows) — git history keeps them for the next hunt; - every env-gated debug printer (fuse/inline/outline/nrc2/f16/SSA dumps and the near-miss tracers) and the capture-tree keepers; - the dormant multi-exit loop structuring (opt-in, measured slower) and the SSA f32->f16 rewrite entry superseded by the gcasm peephole (its shared idiom matchers stay for the f16 store pass); - the runtime WASM2GO_CPU_PORTABLE override in the generated CPU feature probes. The nil-offsets error paths exposed by the threading get nil-safe Config accessors, and the outline share/v128-min/inliner caps become plain constants at their measured production values. Behavior under default options is unchanged: transpiled output for the non-SIMD and EH fixtures is byte-identical to the previous commit, and the only SIMD-bundle difference is the removed env check in the emitted cpufeat runtime files. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
716 lines
21 KiB
Go
716 lines
21 KiB
Go
package transpile_test
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/wasm2go/internal/testfixture"
|
|
"github.com/goccy/wasm2go/transpile"
|
|
)
|
|
|
|
// TestPublicAPI exercises the library entry points — Parse then
|
|
// Translate, and the Transpile convenience wrapper — the same way an
|
|
// external caller would, without touching any internal package.
|
|
func TestPublicAPI(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "wexports")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if _, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "genwasm",
|
|
OutputImportPath: "example.com/test/genwasm",
|
|
BulkExportPrefix: "w_",
|
|
}); err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "package genwasm") {
|
|
t.Errorf("output does not look like generated Go:\n%.200s", out)
|
|
}
|
|
if !strings.Contains(out, "func Inv_0_0(") {
|
|
t.Errorf("BulkExportPrefix option not honored through the public API")
|
|
}
|
|
|
|
// Transpile should be byte-identical to Parse+Translate for the
|
|
// same input and options.
|
|
var oneShot bytes.Buffer
|
|
if _, err := transpile.Transpile(bytes.NewReader(bin), &oneShot, transpile.Options{
|
|
Package: "genwasm",
|
|
OutputImportPath: "example.com/test/genwasm",
|
|
BulkExportPrefix: "w_",
|
|
}); err != nil {
|
|
t.Fatalf("Transpile: %v", err)
|
|
}
|
|
if oneShot.String() != out {
|
|
t.Errorf("Transpile output differs from Parse+Translate")
|
|
}
|
|
|
|
// A malformed input must surface as a Parse error through Transpile,
|
|
// without invoking Translate.
|
|
if _, err := transpile.Transpile(bytes.NewReader([]byte("not a wasm")), &bytes.Buffer{}, transpile.Options{
|
|
Package: "x",
|
|
OutputImportPath: "example.com/x",
|
|
}); err == nil {
|
|
t.Errorf("Transpile: expected error on malformed input")
|
|
}
|
|
}
|
|
|
|
// TestSetMultiPackageThreshold verifies the package-mode override
|
|
// flips the resulting layout. We compare two translations of the
|
|
// same wasm: one with a very high threshold (forces single-file
|
|
// Go output, no `base/`) and one with threshold=0 (forces the
|
|
// multi-package split).
|
|
func TestSetMultiPackageThresholdAPI(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "arith")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
|
|
// Single-file: high threshold cannot be exceeded by a small
|
|
// fixture, so the multi-package layout must NOT trigger.
|
|
restoreHigh := transpile.SetMultiPackageThreshold(1 << 30)
|
|
resHigh, err := transpile.Translate(&bytes.Buffer{}, m, transpile.Options{
|
|
Package: "x", OutputImportPath: "example.com/x",
|
|
})
|
|
restoreHigh()
|
|
if err != nil {
|
|
t.Fatalf("Translate high-threshold: %v", err)
|
|
}
|
|
if _, ok := resHigh.Files["base/base.go"]; ok {
|
|
t.Errorf("high threshold should keep single-file mode but produced base/base.go")
|
|
}
|
|
|
|
// Threshold 0: every module exceeds and lands in multi-package mode.
|
|
restoreZero := transpile.SetMultiPackageThreshold(0)
|
|
resZero, err := transpile.Translate(&bytes.Buffer{}, m, transpile.Options{
|
|
Package: "x", OutputImportPath: "example.com/x",
|
|
})
|
|
restoreZero()
|
|
if err != nil {
|
|
t.Fatalf("Translate zero-threshold: %v", err)
|
|
}
|
|
if _, ok := resZero.Files["base/base.go"]; !ok {
|
|
t.Errorf("threshold=0 should trigger multi-package layout but base/base.go missing")
|
|
}
|
|
}
|
|
|
|
// realAddWasm is a minimal module exporting add:(i32,i32)->i32 = a+b.
|
|
func realAddWasm() []byte {
|
|
return []byte{
|
|
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
|
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,
|
|
0x03, 0x02, 0x01, 0x00,
|
|
0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,
|
|
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b,
|
|
}
|
|
}
|
|
|
|
// TestTranspileEndToEnd drives the full public pipeline — Parse then
|
|
// Translate, which runs codegen AND the gcasm backend — writes the whole
|
|
// bundle to a temp module, and builds+runs it on the host. On amd64/arm64
|
|
// the gcasm-emitted asm supplies the function bodies (the pure bodies are
|
|
// dormant), so a successful `go run` exercises the real shipped path end
|
|
// to end: codegen.Translate, gcasm.Build (capture + transform + emit),
|
|
// and the assembled output itself.
|
|
func TestTranspileEndToEnd(t *testing.T) {
|
|
m, err := transpile.Parse(bytes.NewReader(realAddWasm()))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "pkg",
|
|
OutputImportPath: "gentest/pkg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
writeFile := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("go.mod", []byte("module gentest\n\ngo 1.25.0\n"))
|
|
writeFile("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
writeFile("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
writeFile("pkg/"+name, data)
|
|
}
|
|
writeFile("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"gentest/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
fmt.Println(m.Add(7, 35))
|
|
}
|
|
`))
|
|
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "42" {
|
|
t.Fatalf("Add(7,35) printed %q, want 42\n%s", got, out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHEndToEnd transpiles a wasm module that uses the exception-
|
|
// handling proposal (try/catch/throw — the shape clang emits for
|
|
// setjmp/longjmp) and runs the generated Go. `run` catches an exception thrown
|
|
// with operand 42 and returns it, exercising throw->panic(wasmExc),
|
|
// try/catch->defer/recover, and OpCatchArg end to end.
|
|
func TestTranspileEHEndToEnd(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "eh_e2e")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "pkg",
|
|
OutputImportPath: "ehtest/pkg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
writeFile := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("go.mod", []byte("module ehtest\n\ngo 1.25.0\n"))
|
|
writeFile("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
writeFile("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
writeFile("pkg/"+name, data)
|
|
}
|
|
writeFile("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"ehtest/pkg"
|
|
)
|
|
|
|
func main() {
|
|
fmt.Println(pkg.New().Run())
|
|
}
|
|
`))
|
|
|
|
// Run the default (gcasm, EH functions fall back to pure) build; it must
|
|
// catch the thrown 42.
|
|
for _, tags := range [][]string{nil} {
|
|
args := append([]string{"run"}, tags...)
|
|
args = append(args, ".")
|
|
cmd := exec.Command("go", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run %v failed: %v\n%s", tags, err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "42" {
|
|
t.Fatalf("go run %v: run() printed %q, want 42\n%s", tags, got, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHDelegate exercises the `delegate` EH op: run()'s inner
|
|
// `try ... delegate 0` forwards the thrown 42 to the enclosing try, whose catch
|
|
// yields 42. (rethrow is implemented too, but its E2E awaits goto-emitter EH
|
|
// support — a rethrow inside a catch handler forces the multi-block emitter,
|
|
// which cannot yet emit EH try regions.)
|
|
func TestTranspileEHDelegate(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "eh_delegate")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "pkg",
|
|
OutputImportPath: "ehtest/pkg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
writeFile := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("go.mod", []byte("module ehtest\n\ngo 1.25.0\n"))
|
|
writeFile("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
writeFile("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
writeFile("pkg/"+name, data)
|
|
}
|
|
writeFile("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"ehtest/pkg"
|
|
)
|
|
|
|
func main() {
|
|
fmt.Println(pkg.New().Run())
|
|
}
|
|
`))
|
|
|
|
// gcasm default (EH functions fall back to pure) must yield the
|
|
// delegated operand, 42.
|
|
for _, tags := range [][]string{nil} {
|
|
args := append([]string{"run"}, tags...)
|
|
args = append(args, ".")
|
|
cmd := exec.Command("go", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run %v failed: %v\n%s", tags, err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "42" {
|
|
t.Fatalf("go run %v: run() printed %q, want 42\n%s", tags, got, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTranspileBrTableStructured exercises the structured emitter's br_table
|
|
// support: a 3-case (plus default) switch converging at a shared join. The
|
|
// function has no loops so it is emitted structured (a Go switch), not via the
|
|
// goto fallback. run(sel): 0→11, 1→22, 2→33, default→33.
|
|
func TestTranspileBrTableStructured(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "brtable_switch")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{Package: "pkg", OutputImportPath: "brt/pkg"})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
write := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module brt\n\ngo 1.25.0\n"))
|
|
write("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
write("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
write("pkg/"+name, data)
|
|
}
|
|
write("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"brt/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
fmt.Println(m.Run(0), m.Run(1), m.Run(2), m.Run(5))
|
|
}
|
|
`))
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "11 22 33 33" {
|
|
t.Fatalf("br_table: got %q, want %q\n%s", got, "11 22 33 33", out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHLoopCatch exercises a try/catch inside a multi-exit loop —
|
|
// the shape that used to need the recover-trampoline, and the one clang's
|
|
// setjmp/longjmp lowering produces. Branch-based EH turns the handler into
|
|
// an ordinary block, so this is now plain control flow with no closure, no
|
|
// program-counter dispatch, and no panic on the exception path.
|
|
// run(3) catches 3==x on the 3rd iteration -> 100; run(50) hits i>=10 -> 200.
|
|
func TestTranspileEHLoopCatch(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "eh_trampoline")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{Package: "pkg", OutputImportPath: "eht/pkg"})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
// No panic/recover machinery may survive on the exception path: that is
|
|
// what keeps EH function bodies transformable by the asm backend.
|
|
sources := []string{buf.String()}
|
|
for _, data := range res.Files {
|
|
sources = append(sources, string(data))
|
|
}
|
|
for _, src := range sources {
|
|
for _, banned := range []string{"__pc", "wasm_catch", "wasmExc"} {
|
|
if strings.Contains(src, banned) {
|
|
t.Fatalf("generated EH code still uses %q", banned)
|
|
}
|
|
}
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
write := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module eht\n\ngo 1.25.0\n"))
|
|
write("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
write("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
write("pkg/"+name, data)
|
|
}
|
|
write("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"eht/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
fmt.Println(m.Run(3), m.Run(50))
|
|
}
|
|
`))
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "100 200" {
|
|
t.Fatalf("trampoline EH: got %q, want %q\n%s", got, "100 200", out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHLoopBreakInTry is the regression for the perl.wasm miscompile:
|
|
// a single-exit loop whose TRY BODY holds a br_table that exits the loop. The
|
|
// structured emitter would emit `break wlN` for the loop-exit arm, but that
|
|
// break sits inside the try region's `func() *wasmExc { … }()` closure where the
|
|
// loop label is out of scope, yielding uncompilable Go. jump() must detect the
|
|
// jump crossing the closure and bail to the goto emitter. run(0)=0, run(7)=42.
|
|
func TestTranspileEHLoopBreakInTry(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "eh_loop_break_in_try")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{Package: "pkg", OutputImportPath: "ehl/pkg"})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
write := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module ehl\n\ngo 1.25.0\n"))
|
|
write("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
write("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
write("pkg/"+name, data)
|
|
}
|
|
write("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"ehl/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
fmt.Println(m.Run(0), m.Run(7))
|
|
}
|
|
`))
|
|
// The whole point is that this COMPILES — a `break wlN` across the try
|
|
// closure would fail `go run` here with "break label not defined".
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed (structured emit likely emitted a break across the try closure): %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "0 42" {
|
|
t.Fatalf("eh loop break-in-try: got %q, want %q\n%s", got, "0 42", out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHCatchReturns covers a catch landing pad that leaves the
|
|
// function directly. A landing pad has no CFG predecessors (it is entered by
|
|
// unwinding), and one that ends in `return` has no successors either — so
|
|
// PruneDeadBlocks, whose rule is "no preds and no succs", swept it out of
|
|
// Func.Blocks while the TryRegion kept pointing at it. The recover trampoline
|
|
// then emitted `case <id>: goto L<id>` for a label nobody defined and the
|
|
// generated Go did not compile.
|
|
//
|
|
// `plain` is the minimal shape; `inLoop` is where it was first hit, and there
|
|
// the missing block also tripped the structured emitter's block-count check, so
|
|
// the function fell back to the goto emitter — into the same broken output.
|
|
func TestTranspileEHCatchReturns(t *testing.T) {
|
|
bin := testfixture.Wasm(t, "eh_catch_returns")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{Package: "pkg", OutputImportPath: "ehr/pkg"})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
write := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module ehr\n\ngo 1.25.0\n"))
|
|
write("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
write("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
write("pkg/"+name, data)
|
|
}
|
|
write("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"ehr/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
fmt.Println(m.Plain(0), m.Plain(7), m.InLoop(0), m.InLoop(7))
|
|
}
|
|
`))
|
|
// The whole point is that this COMPILES: a pruned landing pad leaves the
|
|
// trampoline's resume `goto` dangling ("label L3 not defined").
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed (catch landing pad likely pruned): %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "1 42 3 42" {
|
|
t.Fatalf("eh catch-returns: got %q, want %q\n%s", got, "1 42 3 42", out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileEHMultiPackage proves EH try/catch works in MULTI-package
|
|
// output: the wasmExc type + wasm_catch helper live in `base` (exported as
|
|
// WasmExc / Wasm_catch), and the chunk package emitting the try/catch references
|
|
// them cross-package (base.WasmExc, base.Wasm_catch). The threshold override
|
|
// forces the tiny module to split so the EH function lands in a chunk. run()
|
|
// catches the thrown 42.
|
|
func TestTranspileEHMultiPackage(t *testing.T) {
|
|
defer transpile.SetMultiPackageThreshold(0)() // force multi-package split
|
|
|
|
bin := testfixture.Wasm(t, "eh_e2e")
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "wmod",
|
|
OutputImportPath: "ehtest/wmod",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
if len(res.Files) == 0 {
|
|
t.Fatalf("expected multi-package Files, got none")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
write := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("go.mod", []byte("module ehtest\n\ngo 1.25.0\n"))
|
|
for rel, data := range res.Files {
|
|
write(filepath.Join("wmod", rel), data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
write(filepath.Join("wmod", name), data)
|
|
}
|
|
// The exported wasm function `run` becomes the free function wmod.Run(m).
|
|
write("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"ehtest/wmod"
|
|
)
|
|
|
|
func main() {
|
|
fmt.Println(wmod.Run(wmod.New()))
|
|
}
|
|
`))
|
|
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed: %v\n%s", err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "42" {
|
|
t.Fatalf("multi-package EH: run() printed %q, want 42\n%s", got, out)
|
|
}
|
|
}
|
|
|
|
// TestTranspileSetjmpLongjmpEndToEnd is the real-world EH/SjLj test: it
|
|
// transpiles a wasm module built by clang from actual setjmp/longjmp C (via
|
|
// `-mllvm -wasm-enable-sjlj -lsetjmp`) and runs it. This exercises the whole
|
|
// exception-handling stack — the legacy EH opcodes, the __wasm_setjmp /
|
|
// __wasm_setjmp_test / __wasm_longjmp libsetjmp runtime, and the
|
|
// exception-resume loop clang emits (the catch handler branches back to the
|
|
// loop header to re-run from setjmp). run() longjmps with value 7 and returns
|
|
// 100+7 = 107. The wasm is a committed binary fixture (it cannot be rebuilt
|
|
// without a wasi-sdk clang + libsetjmp); see the .README beside it.
|
|
func TestTranspileSetjmpLongjmpEndToEnd(t *testing.T) {
|
|
bin, err := os.ReadFile(filepath.Join("..", "testdata", "sjlj_setjmp_longjmp.wasm"))
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v", err)
|
|
}
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "pkg",
|
|
OutputImportPath: "sjtest/pkg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
writeFile := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("go.mod", []byte("module sjtest\n\ngo 1.25.0\n"))
|
|
writeFile("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
writeFile("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
writeFile("pkg/"+name, data)
|
|
}
|
|
writeFile("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"sjtest/pkg"
|
|
)
|
|
|
|
func main() {
|
|
fmt.Println(pkg.New().Run())
|
|
}
|
|
`))
|
|
|
|
for _, tags := range [][]string{nil} {
|
|
args := append([]string{"run"}, tags...)
|
|
args = append(args, ".")
|
|
cmd := exec.Command("go", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run %v failed: %v\n%s", tags, err, out)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != "107" {
|
|
t.Fatalf("go run %v: run() printed %q, want 107 (longjmp must resume setjmp)\n%s", tags, got, out)
|
|
}
|
|
}
|
|
}
|