Files
wasm2go/transpile/eh_catch_patterns_test.go
ae7e9bd0e5 Support C++-shaped exception handling: multi-clause catch, delegate targets, rethrow depths (#39)
The legacy-EH support added for clang's SjLj lowering only covered the
single-tag patterns SjLj emits. Clang's -fwasm-exceptions C++ output
(e.g. llama.cpp) additionally needs:

- try with multiple catch clauses (catch $a / catch $b / catch_all),
  dispatched by tag in clause order
- catch_all-only trys and rethrow inside catch_all (the cleanup-during-
  unwind shape), including `rethrow l` with l > 0 naming an OUTER
  catch's exception
- `delegate l` forwarding to a specific outer try, skipping every try
  nested in between

Lowering now resolves delegate and rethrow label depths to their target
try regions (ctrl frames track handler state), records each region's
protected-body block set — the CFG alone cannot delimit it, because a
br that exits a try early makes its continuation reachable from inside
— and handles `delegate` closing a try inside a dead region.

The recover-trampoline emitter drops its single-non-catch_all-handler
restriction: every region gets an active flag and its own caught-
exception variable, the dispatch is an innermost-first if-chain with
per-clause tag tests, a delegate arm clears the flags of the regions it
skips, and any edge leaving a protected body clears its flag (a br out
of a try previously left the flag set, so a later unrelated throw could
misroute into the exited try's handler).

The structured emitter registers per-region exception variables so
outer-targeting rethrows resolve correctly, bails to the trampoline
when a delegate's target is not what a plain re-panic would reach or
when a body block escapes the region (its continuation would otherwise
be duplicated inside the protective closure), and closes a try no path
falls out of with trap+spin so Go's termination analysis accepts the
function.

SSA verification computes dominators over the CFG augmented with
virtual entry-to-handler edges, so handler landing pads (which have no
CFG predecessors) may use values defined before their try.

gcasm routes functions that call their own EH closures to the pure
fallback: the outer function of a structured try has no panic/recover
runtime call of its own — the defer/recover lives inside the closure it
calls.

Verified this session: `go test -race -timeout 20m ./...` exit 0 (all
packages ok). New fixtures cover multi-clause dispatch on both emit
paths, catch_all+rethrow, a delegate skipping an intermediate handler,
an outer-targeting rethrow, and the try-exit flag drop. A full
llama.cpp module (wasm32-wasip1, -fwasm-exceptions, SIMD disabled)
transpiles and generates correct text end-to-end. The
googlesql/wasmify five-step e2e chain was NOT run this session.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-08 10:59:07 +09:00

113 lines
3.2 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"
)
// runEHFixture translates fixture (which must export "run" () -> i32), builds
// the generated module with the default backend, executes it, and compares
// run()'s printed result. Shared by the C++-shaped EH pattern tests below.
func runEHFixture(t *testing.T, fixture, want string) {
t.Helper()
bin := testfixture.Wasm(t, fixture)
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())
}
`))
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 != want {
t.Fatalf("run() printed %q, want %s\n%s", got, want, out)
}
}
// TestTranspileEHMultiCatch: one try with catch $a / catch $b / catch_all —
// the clause dispatch must select by tag, in clause order, with catch_all as
// the final default.
func TestTranspileEHMultiCatch(t *testing.T) {
runEHFixture(t, "eh_multi_catch", "132")
}
// TestTranspileEHMultiCatchTrampoline is the same dispatch through the
// recover-trampoline path (a return inside the try body forces it).
func TestTranspileEHMultiCatchTrampoline(t *testing.T) {
runEHFixture(t, "eh_multi_catch_tramp", "139")
}
// TestTranspileEHCatchAllRethrow: catch_all + rethrow 0 — the C++ cleanup-
// during-unwind shape. The rethrown exception keeps tag and payload.
func TestTranspileEHCatchAllRethrow(t *testing.T) {
runEHFixture(t, "eh_catch_all_rethrow", "42")
}
// TestTranspileEHDelegateSkip: delegate 1 forwards past the middle try's
// catch_all straight to the outer catch $a. The middle handler must not run.
func TestTranspileEHDelegateSkip(t *testing.T) {
runEHFixture(t, "eh_delegate_skip", "42")
}
// TestTranspileEHRethrowOuter: rethrow 1 inside an inner catch_all re-raises
// the OUTER catch's exception (42), not the inner one (99).
func TestTranspileEHRethrowOuter(t *testing.T) {
runEHFixture(t, "eh_rethrow_outer", "42")
}
// TestTranspileEHTryExitFlag: a br out of a protected body must deactivate the
// try — a later throw outside it propagates to the caller instead of being
// caught by the exited try's handler.
func TestTranspileEHTryExitFlag(t *testing.T) {
runEHFixture(t, "eh_try_exit_flag", "222")
}