Forwards-compatibility with low-level primitives #108
Description
In order to discuss forwards-compatibility, it's probably best to first get us all on the same page as to how the primitives in #105 would likely express the constructs in this proposal.
The first thing we need is a "universally understood" mark: mark try-catch-exnref : [exnref] -> unreachable
.
Next, translate try ([ti*] -> [to*]) instr* catch instr* end
to the following:
escape $hatch {
mark try-catch-exnref { // [exnref] -> unreachable
escape-to $hatch // uses the exnref on the stack
} within {
instr* // body of try
}
} hatch [exnref] {
instr* // body of catch
} [to*] // output type
Then translate throw $event
to the following:
exnref.new $event // put exnref onto stack
call $throw_exnref
where
func throw_exnref : [exnref] -> unreachable {
stack.walk {
stack.next-mark try-catch-exnref {
stack.exec-mark // passing exnref on stack
}
}
}
and similarly translate rethrow
to simply call $throw_exnref
.
br_on_exn
remains as is, since that's more about reference types than about stacks or control.
One takeaway from this translation is that, theoretically speaking, this proposal is already forwards-compatible with the primitives in #105. However, practically speaking, we care about more than just getting programs to run; we want programs to run correctly, including programs that call other programs or are called by other programs. This is where the importance of stack conventions comes in.
As an example, suppose module A is compiled in the style of #105 whereas module B is compiled in the style of this proposal. If module A calls B, providing B with a callback into A, and B calls that callback that happens to throw an exception (in A), then we have a situation where B's stack frames are sandwiched between A's throw and (presumably) A's catch but where A's exceptions are not implemented using try-catch
. Module A would like to let module B clean up its stack, but module A has its own unwinding state it wants to maintain (e.g. Python building the stack trace as it unwinds the stack). How should module A proceed?
In the current proposal, unwinding code always usurps control and then typically rethrows control to the next try-catch-exnref
mark. That is, it assumes try-catch-exnref
is the sole way to unwind the stack. If, on the other hand, the current proposal were revised to separate unwinding code (e.g. on_unwind instr* do *instr* end
) from exception-handling code (still try/catch
), then #105 could translate on_unwind
to a "universally understood" mark unwinder : [] -> []
and module A's unwinding code could choose to execute unwinder
marks it sees and ignore any try-catch-exnref
marks. As an added benefit, this would work regardless of how B were compiled (assuming B chose to abide by the unwinder
convention), so module A would not need to adjust its implementation strategy to account for B's specific choice of implementation strategy.
Hopefully that gives a since of where the compatibility problem really lies and how the current proposal might be changed to help with forwards-compatibility. It all comes down to what kind of conventions we want to support. More conventions means better compatibility between newer wasm programs and older wasm programs. Fewer conventions means fewer changes, including even no changes. It's possible that the on_unwind
separation above is the sweet spot or that the sweet spot is to simply leave the proposal as is. Regardless of what we decide to do, the current proposal is optimized for a particularly common kind of exception semantics, and I think we should and can maintain that optimization for a common case.