Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebAssembly Exceptions #13130

Open
lbguilherme opened this issue Feb 28, 2023 · 2 comments
Open

WebAssembly Exceptions #13130

lbguilherme opened this issue Feb 28, 2023 · 2 comments

Comments

@lbguilherme
Copy link
Contributor

lbguilherme commented Feb 28, 2023

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:

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 to throw. 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 the recover builtin 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:
    Implement setjmp/longjmp on 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
end

Transforms 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
end

For 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
end

Transforms 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
end

And 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.

@beta-ziliani
Copy link
Member

Thanks for the detailed report Guilherme! I agree that 2 sounds like the best option, but I fear it might not be worth the effort once the other platforms starts supporting it... But I think you're the best one to decide what to do —and to do it, if I'm honest with you...

@lbguilherme
Copy link
Contributor Author

lbguilherme commented Mar 3, 2023

In the future we should support proper wasm exception handling without workarounds, and enable it by default as soon as the rest of the ecosystem does the same. In the mean time, having the workaround (the ast transformation with asyncify) seems the best option.

That said, I have been trying to enable wasm exception handling on LLVM and I completely failed the task.

  • This exception handling mode is enabled by a command line flag on LLVM that is off by default and can't be triggered programmatically. On LLVM 16 this flag will be on by default, at least. Clang adds the LLVM flag internally when it is reading its own cli flags. I tried calling some internal APIs or rebuilding LLVM with it enabled, but everything just crashes.
  • LLVM depends on a libcxxabi build with libunwind symbols. The wasi-sdk doesn't provide these symbols and seems like Emscripten uses a modified version of libunwind. We can't use the same thing because it seems to assume it is executed from a JavaScript context. Also, LLVM refers to some Emscripten symbols directly.
  • The only thing I could find that builds wasm with exception handling enabled is Emscripten + Clang. There is no documentation whatsoever.

This almost looks like a big proof of concept at this point. The end goal is to build existing C++ applications with exceptions for the Web, and they are doing it successfully. Nothing else.

I'll be looking at implementing the AST transformation, given that this is our only viable path for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants