-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
WebAssembly began by focusing on very low level languages (primarily C and C++) and is only now getting support for more "high level" features such as exceptions and a native GC. Crystal needs exceptions to properly function and we need to investigate a way to implement it.
Part 1: the "exception handling" proposal:
There is a ongoing proposal to implement native "throw" and "catch" instructions. Here every exception has a numeric id and we can catch by the type. It fits well into Crystal's model. The proposal itself is still evolving and hasn't been accepted yet. It is still receiving changes, but they are mostly clarifications.
The following tools and runtimes implement the current proposal:
- LLVM
- V8 (Chromium browsers, Node.js, Deno)
- Firefox
- Safari
The following doesn't implement it:
- Wasmtime Exception support bytecodealliance/wasmtime#2049 Implementation strategy for the Exception Handling proposal bytecodealliance/wasmtime#3427
- Wasmer Add support for WebAssembly exceptions in Wasmer wasmerio/wasmer#3100
- WAMR Adding Support for Exception Handling bytecodealliance/wasm-micro-runtime#1884
- WasmEdge
- Wasm3
- Binaryen Asyncity Add partial support for -fwasm-exceptions in Asyncify (#5343) WebAssembly/binaryen#5475 (!!)
Those are pretty significant runtimes outside the browser. They are used mostly in the backend space with serverless offerings. Also, they doesn't see exception handling as something very important to implement, in general.
Part 2: how other languages do it?
-
C++:
It uses the Emscripten toolkit to build C++ into Wasm. It has two modes of operation: using JavaScript exceptions or the exception handling proposal. JS-based exceptions works by calling into JavaScript and having a try-catch block there, that in turn calls into Wasm. Throwing an exception works by invoking JavaScript again tothrow. Finally, it can be built with exceptions disabled, and many C++ libraries work well without exceptions. -
Go:
Both Go and TinyGo panics when you try to use therecoverbuiltin to catch a panic-ing goroutine. That's fine because most Go programs don't need that to function. -
.NET Blazor:
Uses Emscripten and primarily targets the browser. It uses the exception handling proposal. -
Ruby:
Implementsetjmp/longjmpon top of Asyncify, and then use those to implement exceptions in the interpreter. I believe Python does the same. -
Dart/Flutter:
Uses the exception handling proposal.
Part 3: the action plan
Given that simply adding two numbers can raise an exception in Crystal, I don't think we can go very far without some kind of exception support.
We can enable the experimental wasm exception emit on LLVM and have it do all the heavy work for us. I'm not sure how to do it yet, but this is clearly the future-proof path. Today it will mean we would be primarily targeting the browser/node.js and nothing else. The downside is that we need asyncify for Fibers/GC and these two things aren't supported together yet on Binaryen. We would need to wait for it. This is option 1.
An alternative is to implement exceptions as a AST-level syntax transformation with some Asyncify runtime. Here is what I have been thinking:
Given this:
begin
here
code
here
rescue ex : IO::Error
handle_error
ensure
ensure_code
endTransforms into this:
begin
exception, result = __crystal_wasm_rescue do
here
code
here
end
if (ex = exception).is_a?(IO::Error)
exception, result = __crystal_wasm_rescue do
handle_error
end
end
ensure_code
if exception
raise exception
end
result
endFor this to work __crystal_raise would be modified to store the exception in a global state and begin a asyncify unwind. Here __crystal_wasm_rescue is a runtime method that executes the received block. If it detects an asyncify unwind, it will stop it and return the stored exception. So it returns Tuple(Exception?, ReturnType?).
(please see this PR for some explanation about what asyncify is #13107)
We still need to handle return, break and next. Those can be implemented with marker structs, like so:
some_iteration do
begin
if rand > 0.5
next 10
end
if rand > 0.5
return "hi"
end
some_code
rescue ex : IO::Error
break
ensure
ensure_code
end
endTransforms into this:
begin
exception, result = __crystal_wasm_rescue do
if rand > 0.5
next __crystal_wasm_rescue_next 10
end
if rand > 0.5
next __crystal_wasm_rescue_return "hi"
end
some_code
end
if (ex = exception).is_a?(IO::Error)
exception, result = __crystal_wasm_rescue do
next __crystal_wasm_rescue_break
end
end
ensure_code
if exception
raise exception
elsif values = __crystal_wasm_rescue_check_return(result)
return *values
elsif values = __crystal_wasm_rescue_check_break(result)
break *values
elsif values = __crystal_wasm_rescue_check_next(result)
next *values
end
result
endAnd those runtime helpers could be implemented as:
struct BreakMarker(T)
getter values
def initialize(@values : T)
end
end
def __crystal_wasm_rescue_break(*values)
BreakMarker.new(values)
end
def __crystal_wasm_rescue_check_break(result)
result.values if result.is_a? BreakMarker
end
There are a few more details, but this can be expanded later.
This would be an AST transformation that doesn't requires type information (and won't change the final type of any variable). So it would be done early in the pipeline, only for wasm. The result is that we would have exceptions working everywhere, relying only on Asyncify. This is option 2.
What do you think?
I'm leaning towards option 2 because it works everywhere, although it's also the more complicated on our side.