Files
wasm2go/testdata/eh_loop_break_in_try.wat
8c5aec145e codegen: bail structured emit when break/continue crosses a try closure (#26)
* codegen: bail structured emit when break/continue crosses a try closure

A loop whose body contains an EH try region is emitted as

	wlN:
	for {
	    __exc := func() *wasmExc { … }()
	    …
	}

with the protected body inside a func literal. When a br_table (or other
loop-exit branch) inside that protected body targets the loop's follow,
jump() emitted `break wlN` — but the label names the outer `for`, which
is not in scope inside the closure (Go labels are function-scoped and
break/continue cannot cross a func literal). The result was uncompilable
Go:

	p1/p1_pure.go: label wl1 defined and not used
	p1/p1_pure.go: break label not defined: wl1

seen transpiling perl.wasm (this class of loop-in-try-with-br_table-exit
does not occur in the googlesql/python modules, so it surfaced only now).
It is the interaction of the labelled-break work (#25) with the EH try
closure (#22): #22 added an inTryDepth bail for BlockRet but not for the
break/continue path.

Fix: record on each loop frame the inTryDepth at which it was opened, and
have jump() bail (abort structured emission -> goto emitter) when a
break/continue would target a loop opened outside the current try
closure. The goto emitter threads control out of try bodies via return
flags, not lexical break, so it lays the function out correctly.

Regression test: testdata/eh_loop_break_in_try.wat + TestTranspileEHLoopBreakInTry
— a single-exit loop whose try body br_tables out of the loop. Fails on
the old emitter with "break label not defined: wl1"; passes now
(run(0)=0, run(7)=42). Verified end to end: perl.wasm transpiles, the
bundle builds amd64+arm64, and go-perl tests pass; python regenerates
byte-identically (no behavior change where the pattern is absent).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BJSha2EbWhruLZQTZE87oL

* ssa: don't prune a catch landing pad that ends the function

PruneDeadBlocks removes blocks with no predecessors and no successors.
The reasoning is that lowering only ever wires an edge out of a block it
is currently emitting into, so an unreachable block cannot have acquired
a successor either — an isolated block is therefore dead.

A catch landing pad breaks that. It is entered by unwinding, not by a CFG
edge, so it has no predecessors. Usually it has a successor, because the
try's `end` falls through to the region's Post — which is why the pruner
never noticed. But a handler that leaves the function directly (`return`,
`unreachable`, `throw`) has neither, and got swept out of Func.Blocks
while the TryRegion kept pointing at it.

Nothing then emits the pad. The recover trampoline still generates the
`case <id>: goto L<id>` that resumes into it, and the generated Go does
not compile:

    p_pure.go:33:10: label L3 not defined

The structured emitter fares no better: its `len(se.emitted) !=
len(f.Blocks)` check fails, the function falls back to the goto emitter,
and lands in the same broken output. That is how this surfaced — a loop
whose try body branches out of the loop and whose catch returns, where
the fallback was expected to be the safe path.

Keep the landing pads. Genuinely isolated blocks — the pre-allocated
loop-post block a loop that always branches never falls into — still go.

testdata/eh_catch_returns.wat covers both the minimal shape (a try whose
catch just returns, no loop and no br_table needed) and the loop shape it
was found in. TestTranspileEHCatchReturns compiles and runs the generated
Go; before this change `go run` fails with the dangling label.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* codegen: panic on a break/continue across a try closure, at the target tests

`return nil, false` from jump() does not abort structured emission. goTo()
reads it as "target is an ordinary block" and keeps emitting from there,
so the abort only happened downstream and by accident: a jump to the
loop's follow reached the function's return inside the closure and hit
the BlockRet-inside-try bail; a jump to the header re-entered an
already-open loop and duplicated its body until dupCap tripped. Correct
in the end, but resting on other bails rather than on this one.

Panic instead, as the sibling case a few lines below already does for a
jump to an outer loop. emitStructured recovers and falls back to the goto
emitter.

The check has to sit inside each target test, not above them. Above them
it also fired for an ordinary forward jump inside a try — harmless while
it returned false, since that is what an ordinary block returns anyway,
but it shadowed the outer-loop panic and left the emitter walking into
targets it should have refused. Moving it restores structured emission
for functions that have a try inside a loop but no closure-crossing
break: statement coverage goes back from 82.2% to 84.5%, i.e. above the
threshold it had dropped below.

Verified on a large module with no EH at all: the transpiled Go is
byte-identical before and after.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-10 17:58:50 +09:00

45 lines
1.3 KiB
WebAssembly Text Format

;; Regression: a single-exit loop whose TRY BODY contains a br_table that exits
;; the loop. The structured emitter would open `wlN: for {}` for the loop and,
;; for the br_table arm that leaves the loop, emit `break wlN` — but that break
;; sits inside the try region's `func() *wasmExc { … }()` closure, where the
;; label is out of scope (Go labels are function-scoped). Before the fix this
;; produced uncompilable Go ("break label not defined"); the emitter must bail
;; to the goto layout instead.
;;
;; run($x): loop increments $i; the try body calls $t (throws only when $x==7),
;; then br_table on $i: 1 -> continue the loop, otherwise -> exit. catch sets
;; $r=42. So run(0) exits with $r=0 after i reaches 2; run(7) throws on the
;; first iteration and catches -> 42.
(module
(tag $e (param i32))
(func $t (param i32)
local.get 0
i32.const 7
i32.eq
if
i32.const 99
throw $e
end)
(func (export "run") (param $x i32) (result i32)
(local $r i32)
(local $i i32)
block $exit
loop $lp
local.get $i
i32.const 1
i32.add
local.set $i
try
local.get $x
call $t
local.get $i
br_table $exit $lp $exit
catch $e
i32.const 42
local.set $r
br $exit
end
end
end
local.get $r))