Fix panic parsing legacy try/catch with a multi-value handler signature#316
Conversation
guybedford
left a comment
There was a problem hiding this comment.
Reviewed the patch end-to-end against the repo. Confirmed all 4 new tests panic on main with the exact error from #315 and pass with the patch; cargo test --all is green locally.
Diagnosis
Accurate. Legacy Catch/CatchAll are the only arms in the IR builder that fabricate a multi-value handler signature with no guaranteed declared type — Else reuses the parent frame and TryTable carries a real blocktype, so they're incidentally safe.
Strengths
- Test coverage is well-chosen: the
i32case is the minimal repro,catch_allexercises the[] -> resultsarm where it's distinct from both thetryblocktype and the function type, the GC case is the real-world report shape, and multi-catch covers frame stacking. - Mirroring
add_entry_tyis precedent-consistent. - Skipping
Simple(0,0)/(0,1) handlers correctly avoids bloating the type section. Delegatecorrectly pops thetryframe.types.adddeduplicates via theid == next_idcheck, so re-registering an already-declared type is a no-op.
Questions before proceeding
1. Why the pre-pass over the alternative from #315?
#315's own "Suggested fix" is to switch the Catch/CatchAll arms in local_function/mod.rs:1727 / :1744 to InstrSeqType::new instead of existing — a ~6-line localized change instead of a ~62-line pre-pass that re-implements operator scanning. The PR's stated justification ("IR builder holds &Module and runs in parallel") is true, but the lookup could equivalently happen in the existing serial pre-loop in parse_local_functions per-handler without duplicating control-flow tracking. What made the heavier approach preferable? If it's a deliberate separation-of-concerns call (keeping all type-arena mutation outside the parallel IR build), that's defensible — worth stating that rationale in the PR description so reviewers can weigh it.
2. Can the extra walk be skipped when there's no legacy try?
Every function body now gets a second full operator iteration before the real IR build, even for modules with zero legacy exception use (which is the vast majority). For large modules this is non-trivial overhead. A cheap early-out — return immediately once the loop has seen no Try after N operators, or skip the call until a Try is observed — would address this. Could you add one?
Minor
- Doc nit: "every multi-value legacy
catch/catch_allhandler" — the code correctly registers any non-Simpleshape including(N, 0)cases, which aren't strictly "multi-value" in the colloquial sense. Phrasing is slightly imprecise.
Happy to approve once Q1 and Q2 are addressed (either with an explanatory comment for Q1 and an early-out for Q2, or by switching to the InstrSeqType::new approach which obviates both).
A legacy-exceptions `catch` / `catch_all` handler block has a
signature implicit in the encoding — a bare `catch <tag>` opcode
carries no blocktype — equal to `[tag params] -> [try results]`.
That `Type` is therefore never present in `ModuleTypes`.
When the IR builder translates `Operator::Catch` / `CatchAll` it
resolves the handler frame via `InstrSeqType::existing`, which only
matches an already-declared `Type`. When the synthesized
`(params, results)` pair is multi-value and was never declared as a
`func` type, `existing` returns `None` and the `.unwrap()` in
`local_function/mod.rs` panics:
attempted to push a control frame for an instruction sequence
with a type that does not exist
The IR builder holds `&Module` (and runs in parallel), so it cannot
register the missing `Type` itself. This mirrors the function entry
block, whose type is also implicit in the encoding — solved by
`ModuleTypes::add_entry_ty`. This commit applies the same approach:
`register_legacy_catch_block_types` walks each function body before
the parallel IR build (where `self` is still `&mut Module`) and
pre-registers the multi-value handler types.
Reproducer (panics before this change, no GC required):
(module
(tag $e (param i32))
(func $f (result i32)
try (result i32) i32.const 0 catch $e end))
Adds crates/tests/tests/legacy-exceptions-multivalue-catch.rs with
four parse + round-trip regression cases (i32, GC ref, multi-catch,
catch_all); all four panic without this fix and pass with it.
Fixes wasm-bindgen#315.
cc65d43 to
ae623af
Compare
…atch fix `witness instrument` panics on any module containing a legacy `try`/`catch` whose handler signature is multi-value — walrus's IR builder looks the handler type up via `InstrSeqType::existing`, which only matches a declared `func` type, and `.unwrap()`s the `None`. Every Kotlin/Wasm output hits this (legacy `try` + GC ref results), so `examples/languages/kotlin/leap-year` could not be instrumented at all. Root-caused and fixed upstream: - issue: wasm-bindgen/walrus#315 - PR: wasm-bindgen/walrus#316 Until that PR ships in a crates.io release, point `walrus` at the pulseengine/walrus fork, pinned to the exact fix commit (cc65d435) for reproducible builds. Verified: witness-core builds + all 71 tests pass against the fork; `witness instrument` on the Kotlin/Wasm fixture now succeeds (54 branches captured) where it previously panicked. Revert to `walrus = "0.26.x"` from crates.io once PR #316 lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the careful review. Pushed an amended commit ( Q1 — rationale now in the PR descriptionUpdated the description to spell out why the Q3 — doc tightened
Q2 — I tried
|
After walrus 0.26 + the legacy try/catch fix (pinned via witness #37 from the pulseengine/walrus fork; upstream PR wasm-bindgen/walrus#316), `witness instrument` now succeeds on Kotlin/Wasm output — 54 branches captured on the leap-year fixture, was a hard panic before. Decisions stay at 0 because Kotlin emits source maps (.wasm.map V3), not DWARF — that gap is the next witness-side feature. Updates: - docs/cross-language.md: Kotlin row moves from Tier D to Tier B (partial). Cites #37 + walrus #316 and names the remaining source-map work. - examples/languages/kotlin/leap-year/README.md: status switched from "BLOCKED" to "branch-level only", current numbers, what's left to unlock decisions, and the structural caveat that source maps are weaker than DWARF (no inline chains, no address ranges). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…atch fix `witness instrument` panics on any module containing a legacy `try`/`catch` whose handler signature is multi-value — walrus's IR builder looks the handler type up via `InstrSeqType::existing`, which only matches a declared `func` type, and `.unwrap()`s the `None`. Every Kotlin/Wasm output hits this (legacy `try` + GC ref results), so `examples/languages/kotlin/leap-year` could not be instrumented at all. Root-caused and fixed upstream: - issue: wasm-bindgen/walrus#315 - PR: wasm-bindgen/walrus#316 Until that PR ships in a crates.io release, point `walrus` at the pulseengine/walrus fork, pinned to the exact fix commit (cc65d435) for reproducible builds. Verified: witness-core builds + all 71 tests pass against the fork; `witness instrument` on the Kotlin/Wasm fixture now succeeds (54 branches captured) where it previously panicked. Revert to `walrus = "0.26.x"` from crates.io once PR #316 lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for clarifying. LGTM. |
Summary
A legacy-exceptions
catch/catch_allhandler block has a signature implicit in the encoding — a barecatch <tag>carries no blocktype — equal to[tag params] -> [try results]. ThatTypeis never present inModuleTypes, so the IR builder'sInstrSeqType::existinglookup returnsNoneand the.unwrap()inlocal_function/mod.rspanics:Reported in #315.
Reproducer (panics before this change, no GC needed)
Fix — why a pre-pass rather than
InstrSeqType::newat the catch arm#315's own "Suggested fix" was to swap
InstrSeqType::existing → InstrSeqType::newin theCatch/CatchAllarms. That's structurally blocked here:ValidationContext { module: &'a Module, .. }— the IR builder holds&Module, by-design.LocalFunction::parseis invoked from amaybe_parallel!block, so&Moduleis shared across threads.InstrSeqType::newrequires&mut ModuleTypes.Swapping
existing → newat the catch arms would force either giving up parallel parsing or restructuring the borrow shape across the IR builder — a much larger change than the bug warrants.The pre-pass keeps all type-arena mutation in the existing serial setup loop in
parse_local_functions, alongsideadd_entry_ty— which already solves the same shape of problem for the function entry block (its type is also implicit in the encoding). New helperregister_legacy_catch_block_typeswalks the function body once, tracks thetryframe stack, and pre-registers each non-Simplehandler signature.Simple(0 params, ≤1 result) handlers are skipped;types.adddedups, so re-registering a declared type is a no-op.Tests
crates/tests/tests/legacy-exceptions-multivalue-catch.rs— 4 parse + round-trip cases (i32, GC ref, multi-catch,catch_allwith try-with-params). All four panic onmain; all four pass with this change. Fullcargo test --allis green.Fixes #315.
Drafted with AI assistance (Claude Code). The fix was developed against the walrus source; the regression tests were confirmed to fail without it and pass with it, and
cargo test --allwas run green, during that AI-assisted preparation.