[js-api] Custom conversions between exceptions in wasm versus in JS #190
Description
By request, this is a fork from #183.
Motivation
The basic premise is to enable wasm modules to specify how they would like JS exceptions to converted into their wasm exceptions at the module's entry points, and similarly how they would like their wasm exceptions to be converted into JS exceptions at the exit points.
One use case is JS interop. For example, Kotlin will want to have a $__kotlin_exception : [(ref $Throwable)]
exception. To match existing JS interop, Kotlin will want all JS exceptions to be converted into Kotlin exceptions at the boundary: if the JS value is already a Kotlin Throwable
, just make it the payload of the exception, and otherwise wrap the JS value as an externref
field of some (internal) JSException
subclass of Throwable
. Similarly, Kotlin will want Kotlin exceptions that escape to JS to be converted into JS exceptions as follows: if the value is a JSException
, extract and throw its externref
, and otherwise throw the ref $Throwable
as a JS value (using the JS API for GC that lets GC objects be used as JS objects).
Another use case is debugging. The current EH design and JS API only provides good debugging support if you conform to a particular convention. But this convention is too restrictive to conveniently extend this support to features such as C++'s throw;
, Go's defer
, and Java's finally
. With custom conversions, wasm generators can assume all exceptions have a specific tag, and they can explicitly propagate debugging information such as stack traces (as externref
values) so that, at the boundary, they can create JS Errors with this debugging information that already hooks into tooling such as debuggers.
Strategy
The high-level strategy is to have "call an Exported Function" in the JS API call a custom-defined wasm function when a wasm exception is thrown, and to have "create a host function" in the JS API call a custom-defined wasm function when a JS exception is thrown. The former function takes the payload of the exception and returns the externref
to be thrown in JS. The latter function takes the externref
that was thrown and throws the corresponding wasm exception; if the function returns (void), then the externref
is considered unhandled and treated as a trap (just as in the pre-EH JS API).
To clarify, this strategy does not make use of a WebAssembly.Exception
class, nor is there a special tag with payload [externref]
for catching/throwing JS exceptions in wasm with; it simply treats all exceptions from JS uniformly and expects them to be explicitly converted to wasm exceptions at the boundary (if they're to be handled at all).
Also, while I'm using JS as a concrete example, all the designs here work just as well for other embeddings.
Design
Given the high-level strategy, the key design question is how to specify/determine the custom conversion functions to use.
Wasm-to-JS Exception Conversion
A key challenge to keep in mind is that one can create funcref
s from wasm functions even if the functions are not explicitly exported, and these funcref
s can be passed to and called from JS.
So we need to ensure that even the exceptions thrown by these functions are converted, ideally in the same manner as if the function were called as an explicit export on the module.
The design that seems to achieve this most simply is for the module defining an exception tag of type [t*]
to associate a wasm function of type [t*] -> [externref]
. The engine stores this function in the tag, and the wasm-to-JS stubs generated by engines catch wasm exceptions and call the function stored in the tag with the payload of the exception and throw the returned JS value.
We could make this association optional, in which case the default behavior would be to simply trap.
JS-to-Wasm Exception Conversion
A key challenge to keep in mind is that many applications will want to be able to convert arbitrary exceptions from JS into their wasm exceptions.
Any value can be an exception in JS, so this means we cannot make any assumptions about what structure the JS-exception value has.
With this constraint, I can think of three designs.
Instance-Directed Conversion
The first design is for module's to be able to specify a distinguished [externref] -> []
function, akin to how modules can specify a distinguished start
function. When a JS function is used as an import for a wasm instance, that instance's distinguished [externref] -> []
function is used to convert any exceptions it throws. (If none is specified, then the JS exception is propagated as a trap.)
Import-Directed Conversion
The second design offers slightly more control. For each import, a module can specify which function to use to handle JS exceptions.
Type-Directed Conversion
Both of the above have some issues. For one, they both only handle the cases where JS functions are converted to explicit imports. But the js-types
proposal allows you to create wasm funcref
s with just a type signature. So they'd require extending that proposal to create a funcref
with an exception handler (that would typically be exported from the wasm instance the funcref
s eventually get propagated with). To potentially make matters worse, the purpose of WebAssembly/design#1408 is to let tooling bypass js-types
and just use table.set
and the like, but the funcref
s created in this manner would also not have any JS exception handler (so they'd just trap).
Another problem is that they only let the exception handler kick in when the import is a JS function. If, on the other hand, the function comes from wasm then no conversion will happen, which will violate the expectations of the program. I understand this isn't a big issue right now because C++ wasm modules are always surrounded by JS glue, but the JS API we're working on for the GC proposal would eliminate the need for glue. Furthermore, ESM integration will make it more difficult for users to anticipate when a wasm module will get hooked up with another JS module or another wasm module. So this will likely become a problem soon.
The approach that addresses both of these issues and nicely mirrors the wasm-to-JS design is to use a type-directed approach. Given a wasm module that type-checks with unchecked exceptions but uses only the $__cpp_exception
tag, just by adding throws $__cpp_exception
to every function type in the types section (you don't have to touch anything else) you get a wasm module that type-checks using checked exceptions. If you do that, then you can use the exception tag(s) in the throws
clause of an import to determine which conversion function to use. To do so, you associate with each exception tag $exn
a wasm function of type [externref] -> [] (throws $exn)
, and the JS-to-wasm stub for a function type that throws $exn
uses that conversion function. (If the throws
clause specifies multiple exceptions, you try their conversion functions in the sequence listed.)
This works for funcref
s created by WebAssembly/design#1408. It also works for hooking up two wasm instances (via the JS API or ESM): if the imported function throws
tags not accepted by the expected function signature, they are converted to externref
using their associated conversion-to-JS functions, and that externref
is then converted to the expected exception tags using their associated conversion-from-JS functions.