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

Async/await experiment #2221

Merged
merged 19 commits into from Sep 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .eslintrc.await.js
@@ -0,0 +1,6 @@
module.exports = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it can't be done as a magic comment:
eslint/eslint#14854

"extends": "./.eslintrc.js",
"parserOptions": {
"ecmaVersion": 8
},
};
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -29,5 +29,6 @@ module.exports = {
"ArrayBuffer": "readonly",
"globalThis": "readonly",
"Uint8Array": "readonly",
"Promise": "readonly",
}
};
1 change: 1 addition & 0 deletions .rubocop.yml
Expand Up @@ -93,6 +93,7 @@ Style/GlobalVars:
- 'stdlib/nodejs/irb.rb'
- 'stdlib/console.rb'
- 'stdlib/native.rb'
- 'stdlib/await.rb'

Layout/ExtraSpacing:
Exclude:
Expand Down
109 changes: 109 additions & 0 deletions docs/async.md
@@ -0,0 +1,109 @@
# Asynchronous code (PromiseV2 / async / await)

Please be aware that this functionality is marked as experimental and may change
in the future.

In order to disable the warnings that will be shown if you use those experimental
features, add the following line before requiring `promise/v2` or `await` and after
requiring `opal`.

```ruby
`Opal.config.experimental_features_severity = 'ignore'`
```

## PromiseV2

In Opal 1.2 we introduced PromiseV2 which is to replace the default Promise in Opal 2.0
(which will become PromiseV1). Right now it's experimental, but the interface of PromiseV1
stay unchanged and will continue to be supported.

It is imperative that during the transition period you either `require 'promise/v1'` or
`require 'promise/v2'` and then use either `PromiseV1` or `PromiseV2`.

If you write library code it's imperative that you don't require the promise itself, but
detect if `PromiseV2` is defined and use the newer implementation, for instance using the
following code:

```ruby
module MyLibrary
Promise = defined?(PromiseV2) ? PromiseV2 : ::Promise
end
```

The difference between `PromiseV1` and `PromiseV2` is that `PromiseV1` is a pure-Ruby
implementation of a Promise, while `PromiseV2` is reusing the JavaScript `Promise`. Both are
incompatible with each other, but `PromiseV2` can be awaited (see below) and they translate
1 to 1 to the JavaScript native `Promise` (they are bridged; you can directly return a
`Promise` from JavaScript API without a need to translate it). The other difference is that
`PromiseV2` always runs a `#then` block a tick later, while `PromiseV1` would could run it
instantaneously.

## Async/await

In Opal 1.3 we implemented the CoffeeScript pattern of async/await. As of now, it's hidden
behind a magic comment, but this behavior may change in the future.

Example:

```ruby
# await: true

require "await"

def wait_5_seconds
puts "Let's wait 5 seconds..."
sleep(5).await
puts "Done!"
end

wait_5_seconds.__await__
```

It's important to understand what happens under the hood: every scope in which `#__await__` is
encountered will become async, which means that it will return a Promise that will resolve
to a value. This includes methods, blocks and the top scope. This means, that `#__await__` is
infectious and you need to remember to `#__await__` everything along the way, otherwise
a program will finish too early and the values may be incorrect.

[You can take a look at how we ported Minitest to support asynchronous tests.](https://github.com/opal/opal/pull/2221/commits/8383c7b45a94fe4628778f429508b9c08c8948b0) Take note, that
it was ported to use `#await` while the finally accepted version uses `#__await__`.

It is certainly correct to `#__await__` any value, including non-Promises, for instance
`5.__await__` will correctly resolve to `5` (except that it will make the scope an async
function, with all the limitations described above).

The `await` stdlib module includes a few useful functions, like async-aware `each_await`
function and `sleep` that doesn't block the thread. It also includes a method `#await`
which is an alias of `#itself` - it makes sense to auto-await that method.

This approach is certainly incompatible with what Ruby does, but due to a dynamic nature
of Ruby and a different model of JavaScript this was the least invasive way to catch up
with the latest JavaScript trends and support `Promise` heavy APIs and asynchronous code.

## Auto-await

The magic comment also accepts a comma-separated list of methods to be automatically
awaited. An individual value can contain a wildcard character `*`. For instance,
those two blocks of code are equivalent:

```ruby
# await: true

require "await"

[1,2,3].each_await do |i|
p i
sleep(i).__await__
end.__await__
```

```ruby
# await: sleep, *await*

require "await"

[1,2,3].each_await do |i|
p i
sleep i
end
```
56 changes: 56 additions & 0 deletions lib/opal/compiler.rb
Expand Up @@ -166,6 +166,36 @@ def option_value(name, config)

compiler_option :scope_variables, default: []

# @!method async_await
#
# Enable async/await support and optionally enable auto-await.
#
# Use either true, false, an Array of Symbols, a String containing names
# to auto-await separated by a comma or a Regexp.
#
# Auto-await awaits provided methods by default as if .__await__ was added to
# them automatically.
#
# By default, the support is disabled (set to false).
#
# If the config value is not set to false, any calls to #__await__ will be
# translated to ES8 await keyword which makes the scope return a Promise
# and a containing scope will be async (instead of a value, it will return
# a Promise).
#
# If the config value is an array, or a String separated by a comma,
# auto-await is also enabled.
#
# A member of this collection can contain a wildcard character * in which
# case all methods containing a given substring will be awaited.
#
# It can be used as a magic comment, examples:
# ```
# # await: true
# # await: *await*
# # await: *await*, sleep, gets
compiler_option :await, default: false, as: :async_await, magic_comment: true

# @return [String] The compiled ruby code
attr_reader :result

Expand Down Expand Up @@ -255,6 +285,32 @@ def method_calls
@method_calls ||= Set.new
end

alias async_await_before_typecasting async_await
def async_await
if defined? @async_await
@async_await
else
original = async_await_before_typecasting
@async_await = case original
when String
async_await_set_to_regexp(original.split(',').map { |h| h.strip.to_sym })
when Array, Set
async_await_set_to_regexp(original.to_a.map(&:to_sym))
when Regexp, true, false
original
else
raise 'A value of await compiler option can be either ' \
'a Set, an Array, a String or a Boolean.'
end
end
end

def async_await_set_to_regexp(set)
set = set.map { |name| Regexp.escape(name.to_s).gsub('\*', '.*?') }
set = set.join('|')
/^(#{set})$/
end

# This is called when a parsing/processing error occurs. This
# method simply appends the filename and curent line number onto
# the message and raises it.
Expand Down
22 changes: 22 additions & 0 deletions lib/opal/nodes/call.rb
Expand Up @@ -89,6 +89,11 @@ def invoke_using_refinement?
end

def default_compile
if auto_await?
push 'await '
scope.await_encountered = true
end

if invoke_using_refinement?
compile_using_refined_send
elsif invoke_using_send?
Expand Down Expand Up @@ -227,6 +232,12 @@ def sexp_with_arglist
@sexp.updated(nil, [recvr, meth, arglist])
end

def auto_await?
awaited_set = compiler.async_await

awaited_set && awaited_set != true && awaited_set.match?(meth.to_s)
end

# Handle "special" method calls, e.g. require(). Subclasses can override
# this method. If this method returns nil, then the method will continue
# to be generated by CallNode.
Expand Down Expand Up @@ -394,6 +405,17 @@ def using_refinement(arg)
push ")"
end

add_special :__await__ do |compile_default|
if compiler.async_await
push fragment '(await ('
push process(recvr)
push fragment '))'
scope.await_encountered = true
else
compile_default.call
end
end

def push_nesting?
recv = children.first

Expand Down
8 changes: 7 additions & 1 deletion lib/opal/nodes/case.rb
Expand Up @@ -13,7 +13,13 @@ def compile
compiler.in_case do
compile_code

wrap '(function() {', '})()' if needs_closure?
if needs_closure?
if scope.await_encountered
wrap '(await (async function() {', '})())'
else
wrap '(function() {', '})()'
end
end
end
end

Expand Down
14 changes: 12 additions & 2 deletions lib/opal/nodes/class.rb
Expand Up @@ -13,13 +13,23 @@ def compile
name, base = name_and_base
helper :klass

push '(function($base, $super, $parent_nesting) {'
line " var self = $klass($base, $super, '#{name}');"
in_scope do
scope.name = name
compile_body
end
line '})(', base, ', ', super_code, ', $nesting)'

if await_encountered
await_begin = '(await '
await_end = ')'
async = 'async '
parent.await_encountered = true
else
await_begin, await_end, async = '', '', ''
end

unshift "#{await_begin}(#{async}function($base, $super, $parent_nesting) {"
line '})(', base, ', ', super_code, ", $nesting)#{await_end}"
end

def super_code
Expand Down
3 changes: 3 additions & 0 deletions lib/opal/nodes/def.rb
Expand Up @@ -61,6 +61,9 @@ def compile
unshift ') {'
unshift(inline_params)
unshift "function#{function_name}("
if await_encountered
unshift "async "
end
unshift "#{scope_name} = " if scope_name
line '}'

Expand Down
7 changes: 6 additions & 1 deletion lib/opal/nodes/definitions.rb
Expand Up @@ -53,7 +53,12 @@ def compile
compile_inline_children(returned_children, @level)
else
compile_children(returned_children, @level)
wrap '(function() {', '})()'

if scope.parent.await_encountered
wrap '(await (async function() {', '})())'
else
wrap '(function() {', '})()'
end
end
end

Expand Down
8 changes: 7 additions & 1 deletion lib/opal/nodes/if.rb
Expand Up @@ -33,7 +33,13 @@ def compile
push '}'
end

wrap '(function() {', '; return nil; })()' if needs_wrapper?
if needs_wrapper?
if scope.await_encountered
wrap '(await (async function() {', '; return nil; })())'
else
wrap '(function() {', '; return nil; })()'
end
end
end

def truthy
Expand Down
6 changes: 5 additions & 1 deletion lib/opal/nodes/iter.rb
Expand Up @@ -39,7 +39,11 @@ def compile

unshift to_vars

unshift "(#{identity} = function(", inline_params, '){'
if await_encountered
unshift "(#{identity} = async function(", inline_params, '){'
else
unshift "(#{identity} = function(", inline_params, '){'
end
push "}, #{identity}.$$s = self,"
push " #{identity}.$$brk = $brk," if contains_break?
push " #{identity}.$$arity = #{arity},"
Expand Down
14 changes: 12 additions & 2 deletions lib/opal/nodes/module.rb
Expand Up @@ -13,13 +13,23 @@ def compile
name, base = name_and_base
helper :module

push '(function($base, $parent_nesting) {'
line " var self = $module($base, '#{name}');"
in_scope do
scope.name = name
compile_body
end
line '})(', base, ', $nesting)'

if await_encountered
await_begin = '(await '
await_end = ')'
async = 'async '
parent.await_encountered = true
else
await_begin, await_end, async = '', '', ''
end

unshift "#{await_begin}(#{async}function($base, $parent_nesting) {"
line '})(', base, ", $nesting)#{await_end}"
end

private
Expand Down
16 changes: 14 additions & 2 deletions lib/opal/nodes/rescue.rb
Expand Up @@ -49,7 +49,13 @@ def compile

line '}'

wrap '(function() { ', '; })()' if wrap_in_closure?
if wrap_in_closure?
if scope.await_encountered
wrap '(await (async function() { ', '; })())'
else
wrap '(function() { ', '; })()'
end
end
end

def body_sexp
Expand Down Expand Up @@ -134,7 +140,13 @@ def compile
# Wrap a try{} catch{} into a function
# when it's an expression
# or when there's a method call after begin;rescue;end
wrap '(function() { ', '})()' if expr? || recv?
if expr? || recv?
if scope.await_encountered
wrap '(await (async function() { ', '})())'
else
wrap '(function() { ', '})()'
end
end
end

def body_code
Expand Down