diff --git a/CHANGELOG b/CHANGELOG index ec96172..a08e5f1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ - Unreleased + - Add `Context#call_await` and `Context#eval_await`: like `call`/`eval` but block until a returned Promise settles and return the settled value; rejections raise `MiniRacer::RuntimeError` - Fix a race introduced in 0.21.4 where a request sent right after a nested dispatch (e.g. `perform_microtask_checkpoint` or a nested `call` from an attached callback) could be dropped, deadlocking the context - 0.21.4 - 24-06-2026 diff --git a/README.md b/README.md index 6c0b0b0..561fef8 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,38 @@ Performance is slightly better than running `context.eval("hello('George')")` si * compilation of eval'd string is avoided * function arguments don't need to be converted to JSON +### Promises: call_await and eval_await + +`call_await` and `eval_await` work like `call` and `eval`, but when the result is a +Promise they block until it settles and return the settled value. A rejected +promise raises `MiniRacer::RuntimeError`, just like a synchronous `throw`: + +```ruby +context = MiniRacer::Context.new +context.eval("async function f(x) { await Promise.resolve(); return x * 2 }") +context.call_await("f", 21) +# => 42 + +context.eval_await("(async () => 6 * 7)()") +# => 42 + +context.eval("async function boom() { throw new Error('kaboom') }") +context.call_await("boom") +# => raises MiniRacer::RuntimeError (Error: kaboom) +``` + +Non-Promise results pass through unchanged, so `call_await` is a drop-in +superset of `call` (same for `eval_await`/`eval`). + +A promise that never settles blocks forever, just like an infinite loop. The +`timeout:` option and `Context#stop` both interrupt it, raising +`MiniRacer::ScriptTerminatedError`. + +Calling `call_await` or `eval_await` recursively on the same context from an +attached Ruby callback is not supported and raises `MiniRacer::RuntimeError`. +V8 cannot run the nested microtask checkpoint needed to settle such a call. +Synchronous nested `call` and `eval` remain supported. + ### Microtask checkpoints V8 drains its microtask queue (e.g. callbacks queued via `Promise.resolve().then(...)`) automatically when script execution returns to the embedder, so most code "just works": diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index c13dbfe..b7515e1 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -810,7 +810,9 @@ static void dispatch1(Context *c, const uint8_t *p, size_t n) switch (*p) { case 'A': return v8_attach(c->pst, p+1, n-1); case 'C': return v8_timedwait(c, p+1, n-1, v8_call); + case 'D': return v8_timedwait(c, p+1, n-1, v8_call_await); case 'E': return v8_timedwait(c, p+1, n-1, v8_eval); + case 'F': return v8_timedwait(c, p+1, n-1, v8_eval_await); case 'H': return v8_heap_snapshot(c->pst); case 'M': return v8_perform_microtask_checkpoint(c->pst); case 'P': return v8_pump_message_loop(c->pst); @@ -888,7 +890,8 @@ void v8_dispatch(Context *c) pthread_mutex_unlock(&c->mtx); } -// only called when inside v8_call, v8_eval, or v8_pump_message_loop +// only called when inside v8_call, v8_eval (and their async variants), +// or v8_pump_message_loop void v8_roundtrip(Context *c, const uint8_t **p, size_t *n) { pthread_mutex_lock(&c->mtx); @@ -1654,7 +1657,7 @@ static VALUE context_stop(VALUE self) return Qnil; } -static VALUE context_call(int argc, VALUE *argv, VALUE self) +static VALUE context_call_common(int argc, VALUE *argv, VALUE self, char op) { VALUE name, args; VALUE a, e; @@ -1665,8 +1668,8 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self) rb_scan_args(argc, argv, "1*", &name, &args); Check_Type(name, T_STRING); rb_ary_unshift(args, name); - // request is (C)all, [name, args...] array - ser_init1(&s, 'C'); + // request is (C)all or async (D) call, [name, args...] array + ser_init1(&s, op); if (serialize(&s, args)) { ser_reset(&s); rb_raise(runtime_error, "Context.call: %s", s.err); @@ -1678,7 +1681,17 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self) return rb_ary_pop(a); } -static VALUE context_eval(int argc, VALUE *argv, VALUE self) +static VALUE context_call(int argc, VALUE *argv, VALUE self) +{ + return context_call_common(argc, argv, self, 'C'); +} + +static VALUE context_call_await(int argc, VALUE *argv, VALUE self) +{ + return context_call_common(argc, argv, self, 'D'); +} + +static VALUE context_eval_common(int argc, VALUE *argv, VALUE self, char op) { VALUE a, e, source, filename, kwargs; Context *c; @@ -1693,8 +1706,8 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self) if (NIL_P(filename)) filename = rb_str_new_cstr(""); Check_Type(filename, T_STRING); - // request is (E)val, [filename, source] array - ser_init1(&s, 'E'); + // request is (E)val or async (F) eval, [filename, source] array + ser_init1(&s, op); ser_array_begin(&s, 2); add_string(&s, filename); add_string(&s, source); @@ -1706,6 +1719,16 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self) return rb_ary_pop(a); } +static VALUE context_eval(int argc, VALUE *argv, VALUE self) +{ + return context_eval_common(argc, argv, self, 'E'); +} + +static VALUE context_eval_await(int argc, VALUE *argv, VALUE self) +{ + return context_eval_common(argc, argv, self, 'F'); +} + static VALUE context_heap_stats(VALUE self) { VALUE a, h, k, v; @@ -2146,7 +2169,9 @@ void Init_mini_racer_extension(void) rb_define_method(c, "dispose", context_dispose, 0); rb_define_method(c, "stop", context_stop, 0); rb_define_method(c, "call", context_call, -1); + rb_define_method(c, "call_await", context_call_await, -1); rb_define_method(c, "eval", context_eval, -1); + rb_define_method(c, "eval_await", context_eval_await, -1); rb_define_method(c, "heap_stats", context_heap_stats, 0); rb_define_method(c, "heap_snapshot", context_heap_snapshot, 0); rb_define_method(c, "perform_microtask_checkpoint", context_perform_microtask_checkpoint, 0); diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 76551f9..4ff0c61 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -2,6 +2,7 @@ #include "v8-profiler.h" #include "libplatform/libplatform.h" #include "mini_racer_v8.h" +#include #include #include #include @@ -91,6 +92,11 @@ struct State Context *ruby_context; int64_t max_memory; int err_reason; + // TerminateExecution() while idle doesn't make IsExecutionTerminating() true + std::atomic terminate_requested; + // Tracks reentrant call/eval dispatches so nested async calls can fail + // instead of deadlocking in V8's non-reentrant microtask processing. + int javascript_call_depth; bool verbose_exceptions; std::vector callbacks; std::unique_ptr allocator; @@ -586,8 +592,53 @@ extern "C" void v8_attach(State *pst, const uint8_t *p, size_t n) reply_retry(st, err); } +struct JavascriptCallScope +{ + int& depth; + + explicit JavascriptCallScope(int& depth) : depth(depth) { depth++; } + ~JavascriptCallScope() { depth--; } +}; + +void throw_nested_async_call(State& st) +{ + // V8 does not run microtask checkpoints recursively. A nested async call + // from an attached Ruby callback can therefore deadlock, so reject it + // before entering JavaScript. + auto message = v8::String::NewFromUtf8Literal( + st.isolate, "nested async calls are not supported"); + st.isolate->ThrowException(v8::Exception::Error(message)); +} + +// awaits |*result| if it's a promise; false means an exception is pending +bool await_promise(State& st, v8::Local *result) +{ + if (!(*result)->IsPromise()) return true; + auto promise = result->As(); + for (;;) { + v8::MicrotasksScope::PerformCheckpoint(st.isolate); + switch (promise->State()) { + case v8::Promise::kFulfilled: + *result = promise->Result(); + return true; + case v8::Promise::kRejected: + st.isolate->ThrowException(promise->Result()); + return false; + case v8::Promise::kPending: + break; + } + if (st.terminate_requested.load() || st.isolate->IsExecutionTerminating()) + return false; + // blocks until the next task; v8_terminate_execution posts one to + // end the wait on timeout/stop/interrupt + v8::platform::PumpMessageLoop( + platform, st.isolate, + v8::platform::MessageLoopBehavior::kWaitForWork); + } +} + // response is errback [result, err] array -extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) +void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) { State& st = *pst; v8::TryCatch try_catch(st.isolate); @@ -598,6 +649,13 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) des.ReadHeader(st.context).Check(); v8::Local result; int cause = INTERNAL_ERROR; + bool nested = st.javascript_call_depth > 0; + JavascriptCallScope call_scope(st.javascript_call_depth); + if (await && nested) { + throw_nested_async_call(st); + cause = RUNTIME_ERROR; + goto fail; + } { v8::Local request_v; if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail; @@ -645,11 +703,13 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) auto maybe_result_v = function->Call(st.context, obj, args.size(), args.data()); v8::Local result_v; if (!maybe_result_v.ToLocal(&result_v)) goto fail; + if (await && !await_promise(st, &result_v)) goto fail; result = sanitize(st, result_v); } cause = NO_ERROR; fail: - if (st.isolate->IsExecutionTerminating()) { + if (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -664,8 +724,18 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) } } +extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) +{ + v8_call_impl(pst, p, n, false); +} + +extern "C" void v8_call_await(State *pst, const uint8_t *p, size_t n) +{ + v8_call_impl(pst, p, n, true); +} + // response is errback [result, err] array -extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) +void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) { State& st = *pst; v8::TryCatch try_catch(st.isolate); @@ -675,6 +745,13 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) des.ReadHeader(st.context).Check(); v8::Local result; int cause = INTERNAL_ERROR; + bool nested = st.javascript_call_depth > 0; + JavascriptCallScope call_scope(st.javascript_call_depth); + if (await && nested) { + throw_nested_async_call(st); + cause = RUNTIME_ERROR; + goto fail; + } { v8::Local request_v; if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail; @@ -694,11 +771,13 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) cause = RUNTIME_ERROR; auto maybe_result_v = script->Run(st.context); if (!maybe_result_v.ToLocal(&result_v)) goto fail; + if (await && !await_promise(st, &result_v)) goto fail; result = sanitize(st, result_v); } cause = NO_ERROR; fail: - if (st.isolate->IsExecutionTerminating()) { + if (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -713,6 +792,16 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) } } +extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) +{ + v8_eval_impl(pst, p, n, false); +} + +extern "C" void v8_eval_await(State *pst, const uint8_t *p, size_t n) +{ + v8_eval_impl(pst, p, n, true); +} + extern "C" void v8_heap_stats(State *pst) { State& st = *pst; @@ -800,7 +889,8 @@ extern "C" void v8_pump_message_loop(State *pst) if (try_catch.HasCaught()) goto fail; } fail: - if (st.isolate->IsExecutionTerminating()) { + if (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); st.err_reason = NO_ERROR; } @@ -914,7 +1004,8 @@ extern "C" void v8_snapshot(State *pst, const uint8_t *p, size_t n) } cause = NO_ERROR; fail: - if (st.isolate->IsExecutionTerminating()) { + if (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -984,7 +1075,8 @@ extern "C" void v8_warmup(State *pst, const uint8_t *p, size_t n) } cause = NO_ERROR; fail: - if (st.isolate->IsExecutionTerminating()) { + if (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -1008,10 +1100,19 @@ extern "C" void v8_low_memory_notification(State *pst) pst->isolate->LowMemoryNotification(); } -// called from ruby thread +struct WakeupTask : public v8::Task +{ + void Run() final {} +}; + +// called from ruby or watchdog thread extern "C" void v8_terminate_execution(State *pst) { + pst->terminate_requested.store(true); pst->isolate->TerminateExecution(); + // wake await_promise's message loop pump + platform->GetForegroundTaskRunner(pst->isolate) + ->PostTask(std::make_unique()); } // called from ruby thread @@ -1019,6 +1120,7 @@ extern "C" void v8_cancel_terminate_execution(State *pst) { // TerminateExecution can race with V8 completing and queue a termination // for the next entry without IsExecutionTerminating() becoming true. + pst->terminate_requested.store(false); pst->isolate->CancelTerminateExecution(); } diff --git a/ext/mini_racer_extension/mini_racer_v8.h b/ext/mini_racer_extension/mini_racer_v8.h index 81394ce..3d34e9f 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -39,7 +39,9 @@ struct State *v8_thread_init(struct Context *c, const uint8_t *snapshot_buf, int verbose_exceptions); // calls v8_thread_main void v8_attach(struct State *pst, const uint8_t *p, size_t n); void v8_call(struct State *pst, const uint8_t *p, size_t n); +void v8_call_await(struct State *pst, const uint8_t *p, size_t n); void v8_eval(struct State *pst, const uint8_t *p, size_t n); +void v8_eval_await(struct State *pst, const uint8_t *p, size_t n); void v8_heap_stats(struct State *pst); void v8_heap_snapshot(struct State *pst); void v8_perform_microtask_checkpoint(struct State *pst); diff --git a/lib/mini_racer/shared.rb b/lib/mini_racer/shared.rb index b66e59d..0fc8e8e 100644 --- a/lib/mini_racer/shared.rb +++ b/lib/mini_racer/shared.rb @@ -188,6 +188,14 @@ def call(function_name, *arguments) ensure_gc_thread if @ensure_gc_after_idle end + def eval_await(*) + raise MiniRacer::Error, "eval_await is not supported on TruffleRuby" + end + + def call_await(*) + raise MiniRacer::Error, "call_await is not supported on TruffleRuby" + end + def dispose return if @disposed isolate_mutex.synchronize do diff --git a/test/async_test.rb b/test/async_test.rb new file mode 100644 index 0000000..d8d6d4a --- /dev/null +++ b/test/async_test.rb @@ -0,0 +1,222 @@ +require "test_helper" +require "timeout" + +class MiniRacerAsyncTest < Minitest::Test + def setup + if RUBY_ENGINE == "truffleruby" + skip "TruffleRuby does not implement call_await/eval_await" + end + end + + def test_call_await_returns_settled_value + context = MiniRacer::Context.new + context.eval("async function f(x) { return x * 2 }") + assert_equal 42, context.call_await("f", 21) + end + + def test_call_await_passes_arguments_exactly + context = MiniRacer::Context.new + context.eval("async function count() { return arguments.length }") + assert_equal 3, context.call_await("count", 1, 2, 3) + assert_equal 0, context.call_await("count") + end + + def test_call_await_microtask_chain + context = MiniRacer::Context.new + context.eval(<<~JS) + async function chain() { + let n = 0; + for (let i = 0; i < 100; i++) { + await Promise.resolve(); + n++; + } + return n; + } + JS + assert_equal 100, context.call_await("chain") + end + + def test_call_await_non_promise_passthrough + context = MiniRacer::Context.new + context.eval("function sync(x) { return x + 1 }") + assert_equal 42, context.call_await("sync", 41) + end + + def test_call_await_rejection_raises_runtime_error + context = MiniRacer::Context.new + context.eval("async function boom() { throw new Error('kaboom') }") + err = assert_raises(MiniRacer::RuntimeError) { context.call_await("boom") } + assert_includes err.message, "kaboom" + end + + def test_call_await_rejection_with_non_error_value + context = MiniRacer::Context.new + context.eval("function nope() { return Promise.reject('just a string') }") + assert_raises(MiniRacer::RuntimeError) { context.call_await("nope") } + end + + def test_call_await_non_existing_function + context = MiniRacer::Context.new + assert_raises(MiniRacer::RuntimeError) { context.call_await("missing") } + end + + def test_eval_await_top_level_promise + context = MiniRacer::Context.new + result = + context.eval_await( + "(async () => { await Promise.resolve(); return 6 * 7 })()" + ) + assert_equal 42, result + end + + def test_eval_await_non_promise + context = MiniRacer::Context.new + assert_equal 2, context.eval_await("1 + 1") + end + + def test_eval_await_filename + context = MiniRacer::Context.new + err = + assert_raises(MiniRacer::RuntimeError) do + context.eval_await("Promise.reject(new Error('x'))", filename: "foo.js") + end + assert_match(/foo\.js/, err.backtrace[0]) + end + + def test_call_await_ruby_callback_in_awaited_chain + context = MiniRacer::Context.new + context.attach("rubyAdd", proc { |a, b| a + b }) + context.eval(<<~JS) + async function viaRuby() { + await Promise.resolve(); + return rubyAdd(20, 22); + } + JS + assert_equal 42, context.call_await("viaRuby") + end + + def test_call_await_ruby_callback_exception_propagates + context = MiniRacer::Context.new + context.attach("rubyBoom", proc { raise "ruby boom" }) + context.eval(<<~JS) + async function boomRuby() { + await Promise.resolve(); + return rubyBoom(); + } + JS + err = assert_raises(RuntimeError) { context.call_await("boomRuby") } + assert_includes err.message, "ruby boom" + end + + def test_nested_call_await_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach("rubyCallsAsync", proc { context.call_await("inner") }) + context.eval(<<~JS) + async function inner() { + await Promise.resolve(); + return 42; + } + + async function outer() { + await Promise.resolve(); + return rubyCallsAsync(); + } + JS + + err = + assert_raises(MiniRacer::RuntimeError) do + Timeout.timeout(2) { context.call_await("outer") } + end + assert_includes err.message, "nested async call" + assert_equal 2, context.eval("1 + 1") + end + + def test_nested_eval_await_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach( + "rubyEvalsAsync", + proc do + context.eval_await( + "(async () => { await Promise.resolve(); return 42 })()" + ) + end + ) + context.eval(<<~JS) + async function outer() { + await Promise.resolve(); + return rubyEvalsAsync(); + } + JS + + err = + assert_raises(MiniRacer::RuntimeError) do + Timeout.timeout(2) { context.call_await("outer") } + end + assert_includes err.message, "nested async call" + assert_equal 2, context.eval("1 + 1") + end + + def test_eval_await_delayed_task + context = MiniRacer::Context.new + result = context.eval_await(<<~JS) + (async () => { + const i32 = new Int32Array(new SharedArrayBuffer(4)); + return (await Atomics.waitAsync(i32, 0, 0, 20).value); + })() + JS + assert_equal "timed-out", result + end + + def test_never_settling_promise_hits_timeout + context = MiniRacer::Context.new(timeout: 200) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval_await("new Promise(() => {})") + end + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + assert_operator elapsed, :<, 5 + assert_equal 2, context.eval("1 + 1") + end + + def test_never_settling_promise_interrupted_by_stop + context = MiniRacer::Context.new + stopper = + Thread.new do + sleep 0.1 + context.stop + end + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval_await("new Promise(() => {})") + end + stopper.join + assert_equal 2, context.eval("1 + 1") + end + + def test_never_settling_promise_interrupted_by_thread_kill + context = MiniRacer::Context.new + thread = + Thread.new do + context.eval_await("new Promise(() => {})") + rescue MiniRacer::ScriptTerminatedError + nil + end + sleep 0.1 + thread.kill + assert thread.join(3), "awaiting thread did not stop" + assert_equal 2, context.eval("1 + 1") + end + + def test_call_sync_does_not_await + context = MiniRacer::Context.new + context.eval("async function f() { return 42 }") + assert_equal({}, context.call("f")) + end + + def test_dispose_after_call_await + context = MiniRacer::Context.new + context.eval("async function f() { return 1 }") + assert_equal 1, context.call_await("f") + context.dispose + assert_raises(MiniRacer::ContextDisposedError) { context.call_await("f") } + end +end diff --git a/test/function_test.rb b/test/function_test.rb index 01e90f0..1dbc51c 100644 --- a/test/function_test.rb +++ b/test/function_test.rb @@ -47,6 +47,19 @@ def test_args_types assert_equal "I need 1,2,3 bars", res end + def test_arguments_passed_exactly + context = MiniRacer::Context.new + context.eval("function count() { return arguments.length }") + assert_equal 2, context.call("count", 1, 2) + assert_equal 0, context.call("count") + end + + def test_trailing_hash_is_positional_argument + context = MiniRacer::Context.new + context.eval("function echo(h) { return h }") + assert_equal({ "key" => "value" }, context.call("echo", key: "value")) + end + def test_complex_return context = MiniRacer::Context.new context.eval("function f(x, y) { return { vx: x, vy: y, array: [x, y] } }") diff --git a/test/single_threaded_test.rb b/test/single_threaded_test.rb index 9340f83..60ac7aa 100644 --- a/test/single_threaded_test.rb +++ b/test/single_threaded_test.rb @@ -51,6 +51,23 @@ def test_ruby_callback_from_javascript RUBY end + def test_call_await_and_eval_await + assert_single_threaded_script <<~'RUBY' + context = MiniRacer::Context.new + context.eval("async function f(x) { await Promise.resolve(); return x * 2 }") + raise "bad async call" unless context.call_await("f", 21) == 42 + raise "bad async eval" unless context.eval_await("(async () => 6 * 7)()") == 42 + + context = MiniRacer::Context.new(timeout: 200) + begin + context.eval_await("new Promise(() => {})") + raise "expected termination" + rescue MiniRacer::ScriptTerminatedError + end + raise "context unusable after termination" unless context.eval("1 + 1") == 2 + RUBY + end + def test_nested_javascript_ruby_javascript_call assert_single_threaded_script <<~'RUBY' context = MiniRacer::Context.new