Skip to content

WebAssembly Exceptions #13130

@lbguilherme

Description

@lbguilherme

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions