Skip to content

Promise Scheduler #112

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

Merged
merged 11 commits into from
Nov 6, 2022
Merged
76 changes: 76 additions & 0 deletions ext/js/lib/js.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,69 @@
module JS
Undefined = JS.eval("return undefined")
Null = JS.eval("return null")

class PromiseScheduler
Task = Struct.new(:fiber, :status, :value)

def initialize(main_fiber)
@tasks = []
@is_spinning = false
@loop_fiber =
Fiber.new do
loop do
while task = @tasks.shift
task.fiber.transfer(task.value, task.status)
end
@is_spinning = false
main_fiber.transfer
end
end
end

def await(promise)
current = Fiber.current
promise.call(
:then,
->(value) { enqueue Task.new(current, :success, value) },
->(value) { enqueue Task.new(current, :failure, value) }
)
value, status = @loop_fiber.transfer
raise JS::Error.new(value) if status == :failure
value
end

def enqueue(task)
@tasks << task
unless @is_spinning
@is_spinning = true
JS.global.queueMicrotask -> { @loop_fiber.transfer }
end
end
end

@promise_scheduler = PromiseScheduler.new Fiber.current

def self.promise_scheduler
@promise_scheduler
end

private

def self.__eval_async_rb(rb_code, future)
Fiber
.new do
future.resolve JS::Object.wrap(
Kernel.eval(
rb_code.to_s,
TOPLEVEL_BINDING,
"eval_async"
)
)
rescue => e
future.reject JS::Object.wrap(e)
end
.transfer
end
end

class JS::Object
Expand All @@ -30,6 +93,19 @@ def respond_to_missing?(sym, include_private)
return true if super
self[sym].typeof == "function"
end

# Await a JavaScript Promise like `await` in JavaScript.
# This method looks like a synchronous method, but it actually runs asynchronously using fibers.
#
# JS.eval("return new Promise((ok) => setTimeout(ok(42), 1000))").await # => 42 (after 1 second)
# JS.global.fetch("https://example.com").await # => [object Response]
# JS.eval("return 42").await # => 42
# JS.eval("return new Promise((ok, err) => err(new Error())").await # => raises JS::Error
def await
# Promise.resolve wrap a value or flattens promise-like object and its thenable chain
promise = JS.global[:Promise].resolve(self)
JS.promise_scheduler.await(promise)
end
end

# A wrapper class for JavaScript Error to allow the Error to be thrown in Ruby.
Expand Down
4 changes: 0 additions & 4 deletions lib/ruby_wasm/build_system/product/crossruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,6 @@ def artifact
File.join(@rubies_dir, "ruby-#{name}.tar.gz")
end

def built?
File.exist?(artifact)
end

def extinit_obj
"#{ext_build_dir}/extinit.o"
end
Expand Down
1 change: 0 additions & 1 deletion lib/ruby_wasm/rake_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ def initialize(

desc "Cross-build Ruby for #{@target}"
task name do
next if @crossruby.built?
@crossruby.build
end
namespace name do
Expand Down
33 changes: 29 additions & 4 deletions packages/npm-packages/ruby-head-wasm-wasi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,18 @@ jnode.setRNode(rnode);
- [eval](#eval)
- [Parameters](#parameters-3)
- [Examples](#examples-1)
- [wrap](#wrap)
- [evalAsync](#evalasync)
- [Parameters](#parameters-4)
- [Examples](#examples-2)
- [RbValue](#rbvalue)
- [call](#call)
- [wrap](#wrap)
- [Parameters](#parameters-5)
- [Examples](#examples-3)
- [toPrimitive](#toprimitive)
- [RbValue](#rbvalue)
- [call](#call)
- [Parameters](#parameters-6)
- [Examples](#examples-4)
- [toPrimitive](#toprimitive)
- [Parameters](#parameters-7)
- [toString](#tostring)
- [toJS](#tojs)
- [RbError](#rberror)
Expand Down Expand Up @@ -217,6 +220,28 @@ console.log(result.toString()); // 3

Returns **any** the result of the last expression

#### evalAsync

Runs a string of Ruby code with top-level `JS::Object#await`
Returns a promise that resolves when execution completes.

##### Parameters

- `code` The Ruby code to run

##### Examples

```javascript
const text = await vm.evalAsync(`
require 'js'
response = JS.global.fetch('https://example.com').await
response.text.await
`);
console.log(text.toString()); // <html>...</html>
```

Returns **any** a promise that resolves to the result of the last expression

#### wrap

Wrap a JavaScript value into a Ruby JS::Object
Expand Down
38 changes: 38 additions & 0 deletions packages/npm-packages/ruby-wasm-wasi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ export class RubyVM {
return evalRbCode(this, this.privateObject(), code);
}

/**
* Runs a string of Ruby code with top-level `JS::Object#await`
* Returns a promise that resolves when execution completes.
* @param code The Ruby code to run
* @returns a promise that resolves to the result of the last expression
*
* @example
* const text = await vm.evalAsync(`
* require 'js'
* response = JS.global.fetch('https://example.com').await
* response.text.await
* `);
* console.log(text.toString()); // <html>...</html>
*/
evalAsync(code: string): Promise<RbValue> {
const JS = this.eval("require 'js'; JS");
return new Promise((resolve, reject) => {
JS.call(
"__eval_async_rb",
this.wrap(code),
this.wrap({
resolve,
reject: (error: RbValue) => {
reject(
new RbError(
this.exceptionFormatter.format(
error,
this,
this.privateObject()
)
)
);
},
})
);
});
}

/**
* Wrap a JavaScript value into a Ruby JS::Object
* @param value The value to convert to RbValue
Expand Down
1 change: 1 addition & 0 deletions packages/npm-packages/ruby-wasm-wasi/test/test_unit.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require_relative "./unit/test_js"
require_relative "./unit/test_object"
require_relative "./unit/test_error"
require_relative "./unit/test_async"
45 changes: 45 additions & 0 deletions packages/npm-packages/ruby-wasm-wasi/test/unit/test_async.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require "test-unit"
require "js"

class JS::TestAsync < Test::Unit::TestCase
def test_await_promise_resolve
promise = JS.eval("return Promise.resolve(42)")
assert_equal 42, promise.await.to_i
# Promise can be resolved multiple times.
assert_equal 42, promise.await.to_i
end

def test_await_promise_reject
promise = JS.eval("return Promise.reject(42)")
e = assert_raise(JS::Error) { promise.await }
assert_equal "42", e.message
end

def test_await_promise_chained
promise = JS.eval("return Promise.resolve(42).then(x => x + 1)")
assert_equal 43, promise.await.to_i
end

def test_await_non_promise
assert_equal 42, JS.eval("return 42").await.to_i
end

def make_promise_and_continuation
JS.eval(<<~JS)
let continuation = null;
const promise = new Promise((resolve, reject) => {
continuation = { resolve, reject };
});
return { promise, continuation };
JS
end

def test_concurrent_promises
pac0 = make_promise_and_continuation
pac1 = make_promise_and_continuation
pac0[:continuation].resolve(42)
pac1[:continuation].resolve(43)
assert_equal 43, pac1[:promise].await.to_i
assert_equal 42, pac0[:promise].await.to_i
end
end
37 changes: 37 additions & 0 deletions packages/npm-packages/ruby-wasm-wasi/test/vm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,41 @@ eval:11:in \`<main>'`);
const hash = vm.eval(`Hash.new`);
hash.call("store", vm.eval(`"key1"`), vm.wrap(new Object()));
});

test("async eval over microtasks", async () => {
const vm = await initRubyVM();
const result = await vm.evalAsync(`
require 'js'
o = JS.eval(<<~JS)
return {
async_func: () => {
return new Promise((resolve) => {
queueMicrotask(() => {
resolve(42)
});
});
}
}
JS
o.async_func.await
`);
expect(result.toString()).toBe("42");
});

test("async eval multiple times", async () => {
const vm = await initRubyVM();
vm.eval(`require "js"`);
const ret0 = await vm.evalAsync(`JS.global[:Promise].resolve(42).await`);
expect(ret0.toString()).toBe("42");
const ret1 = await vm.evalAsync(`JS.global[:Promise].resolve(43).await`);
expect(ret1.toString()).toBe("43");
});

test("await outside of evalAsync", async () => {
const vm = await initRubyVM();
const result = vm.eval(
`require "js"; JS.global[:Promise].resolve(42).await`
);
expect(result.call("nil?").toString()).toBe("true");
});
});
10 changes: 8 additions & 2 deletions packages/npm-packages/ruby-wasm-wasi/tools/run-test-unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ const instantiate = async (rootTestFile) => {
const wasi = new WASI({
stdio: "inherit",
args: ["ruby.wasm"].concat(process.argv.slice(2)),
env: process.env,
env: {
...process.env,
// Extend fiber stack size to be able to run test-unit
"RUBY_FIBER_MACHINE_STACK_SIZE": String(1024 * 1024 * 20),
},
preopens: preopens,
});

Expand All @@ -47,7 +51,9 @@ const main = async () => {
const rootTestFile = "/__root__/test/test_unit.rb";
const { vm } = await instantiate(rootTestFile);

vm.eval(`
Error.stackTraceLimit = Infinity;

await vm.evalAsync(`
# HACK: Until we've fixed the issue in the test-unit or power_assert
# See https://github.com/test-unit/test-unit/pull/221
module Kernel
Expand Down