crystal-lang / crystal

The Crystal Programming Language
https://crystal-lang.org
Apache License 2.0
19.32k stars 1.61k forks source link

WebAssembly Exceptions #13130

Open lbguilherme opened 1 year ago

lbguilherme commented 1 year ago

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:

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?

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 https://github.com/crystal-lang/crystal/pull/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 commented 1 year ago

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 commented 1 year ago

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