From bb500eb2e440f408403ed82bcda9f13941cc07e6 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 10 Aug 2026 17:12:26 +0000 Subject: [PATCH 1/3] FEATURE: add Context#call_async and Context#eval_async These work like call and eval, except that when the result is a promise they block until it settles and return the settled value. A rejected promise raises MiniRacer::RuntimeError, like a synchronous throw, and non-promise results are returned as-is. While waiting, the V8 thread alternates between draining the microtask queue and pumping the platform message loop in wait-for-work mode, so there is no polling: microtask chains settle immediately, and delayed or background work (Atomics.waitAsync timers, async wasm compilation) wakes the loop when its tasks are posted. TerminateExecution doesn't wake a parked message loop, and when called while no JS is running it only queues a termination for the next JS entry. v8_terminate_execution therefore also sets a flag on the State and posts a no-op wakeup task, so the timeout watchdog, Context#stop and Ruby thread interrupts can all end a pending await. A promise that can never settle blocks like an infinite loop until one of those stops it. The new methods use two new request opcodes ('D' and 'F') sharing the existing v8_call/v8_eval implementations. Ruby callbacks invoked while waiting go through the usual nested-dispatch path, and exceptions they raise propagate out through the promise rejection. TruffleRuby raises MiniRacer::Error. --- CHANGELOG | 1 + README.md | 27 +++ .../mini_racer_extension.c | 39 +++- ext/mini_racer_extension/mini_racer_v8.cc | 83 ++++++++- ext/mini_racer_extension/mini_racer_v8.h | 2 + lib/mini_racer/shared.rb | 8 + test/async_test.rb | 173 ++++++++++++++++++ test/function_test.rb | 13 ++ test/single_threaded_test.rb | 17 ++ 9 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 test/async_test.rb diff --git a/CHANGELOG b/CHANGELOG index ec961721..67c533d3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ - Unreleased + - Add `Context#call_async` and `Context#eval_async`: 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 6c0b0b0c..9708e457 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,33 @@ 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_async and eval_async + +`call_async` and `eval_async` 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_async("f", 21) +# => 42 + +context.eval_async("(async () => 6 * 7)()") +# => 42 + +context.eval("async function boom() { throw new Error('kaboom') }") +context.call_async("boom") +# => raises MiniRacer::RuntimeError (Error: kaboom) +``` + +Non-Promise results pass through unchanged, so `call_async` is a drop-in +superset of `call` (same for `eval_async`/`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`. + ### 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 c13dbfeb..fe39d3f7 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_async); case 'E': return v8_timedwait(c, p+1, n-1, v8_eval); + case 'F': return v8_timedwait(c, p+1, n-1, v8_eval_async); 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_async(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_async(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_async", context_call_async, -1); rb_define_method(c, "eval", context_eval, -1); + rb_define_method(c, "eval_async", context_eval_async, -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 76551f96..dc9e085b 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,8 @@ struct State Context *ruby_context; int64_t max_memory; int err_reason; + // TerminateExecution() while idle doesn't make IsExecutionTerminating() true + std::atomic terminate_requested; bool verbose_exceptions; std::vector callbacks; std::unique_ptr allocator; @@ -586,8 +589,35 @@ extern "C" void v8_attach(State *pst, const uint8_t *p, size_t n) reply_retry(st, err); } +// 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); @@ -645,11 +675,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 +696,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_async(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); @@ -694,11 +736,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 +757,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_async(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 +854,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 +969,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 +1040,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 +1065,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 +1085,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 81394ce4..b2ea7bed 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_async(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_async(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 b66e59d7..3ac3364d 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_async(*) + raise MiniRacer::Error, "eval_async is not supported on TruffleRuby" + end + + def call_async(*) + raise MiniRacer::Error, "call_async 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 00000000..c2f1d93e --- /dev/null +++ b/test/async_test.rb @@ -0,0 +1,173 @@ +require "test_helper" + +class MiniRacerAsyncTest < Minitest::Test + def setup + if RUBY_ENGINE == "truffleruby" + skip "TruffleRuby does not implement call_async/eval_async" + end + end + + def test_call_async_returns_settled_value + context = MiniRacer::Context.new + context.eval("async function f(x) { return x * 2 }") + assert_equal 42, context.call_async("f", 21) + end + + def test_call_async_passes_arguments_exactly + context = MiniRacer::Context.new + context.eval("async function count() { return arguments.length }") + assert_equal 3, context.call_async("count", 1, 2, 3) + assert_equal 0, context.call_async("count") + end + + def test_call_async_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_async("chain") + end + + def test_call_async_non_promise_passthrough + context = MiniRacer::Context.new + context.eval("function sync(x) { return x + 1 }") + assert_equal 42, context.call_async("sync", 41) + end + + def test_call_async_rejection_raises_runtime_error + context = MiniRacer::Context.new + context.eval("async function boom() { throw new Error('kaboom') }") + err = assert_raises(MiniRacer::RuntimeError) { context.call_async("boom") } + assert_includes err.message, "kaboom" + end + + def test_call_async_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_async("nope") } + end + + def test_call_async_non_existing_function + context = MiniRacer::Context.new + assert_raises(MiniRacer::RuntimeError) { context.call_async("missing") } + end + + def test_eval_async_top_level_promise + context = MiniRacer::Context.new + result = + context.eval_async( + "(async () => { await Promise.resolve(); return 6 * 7 })()" + ) + assert_equal 42, result + end + + def test_eval_async_non_promise + context = MiniRacer::Context.new + assert_equal 2, context.eval_async("1 + 1") + end + + def test_eval_async_filename + context = MiniRacer::Context.new + err = + assert_raises(MiniRacer::RuntimeError) do + context.eval_async("Promise.reject(new Error('x'))", filename: "foo.js") + end + assert_match(/foo\.js/, err.backtrace[0]) + end + + def test_call_async_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_async("viaRuby") + end + + def test_call_async_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_async("boomRuby") } + assert_includes err.message, "ruby boom" + end + + def test_eval_async_delayed_task + context = MiniRacer::Context.new + result = context.eval_async(<<~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_async("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_async("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_async("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_async + context = MiniRacer::Context.new + context.eval("async function f() { return 1 }") + assert_equal 1, context.call_async("f") + context.dispose + assert_raises(MiniRacer::ContextDisposedError) { context.call_async("f") } + end +end diff --git a/test/function_test.rb b/test/function_test.rb index 01e90f04..1dbc51cb 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 9340f831..86faa3b5 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_async_and_eval_async + 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_async("f", 21) == 42 + raise "bad async eval" unless context.eval_async("(async () => 6 * 7)()") == 42 + + context = MiniRacer::Context.new(timeout: 200) + begin + context.eval_async("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 From 6c2630a0b73d2fc63be87d12549adebdd3e1c53a Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Tue, 11 Aug 2026 18:22:51 +1000 Subject: [PATCH 2/3] FIX: reject nested async context calls Raise MiniRacer::RuntimeError when call_async or eval_async is invoked recursively from an attached Ruby callback. V8 cannot run nested microtask checkpoints, so these calls would otherwise deadlock. Document the limitation and cover both async entry points while ensuring the context remains usable. --- README.md | 5 +++ ext/mini_racer_extension/mini_racer_v8.cc | 35 +++++++++++++++++ test/async_test.rb | 47 +++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/README.md b/README.md index 9708e457..99455b7d 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,11 @@ 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_async` or `eval_async` 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_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index dc9e085b..f4885fe2 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -94,6 +94,9 @@ struct State 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; @@ -589,6 +592,24 @@ 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) { @@ -628,6 +649,13 @@ void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) 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; @@ -717,6 +745,13 @@ void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) 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; diff --git a/test/async_test.rb b/test/async_test.rb index c2f1d93e..d5ed1193 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "timeout" class MiniRacerAsyncTest < Minitest::Test def setup @@ -107,6 +108,52 @@ def test_call_async_ruby_callback_exception_propagates assert_includes err.message, "ruby boom" end + def test_nested_call_async_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach("rubyCallsAsync", proc { context.call_async("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_async("outer") } + end + assert_includes err.message, "nested async call" + assert_equal 2, context.eval("1 + 1") + end + + def test_nested_eval_async_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach( + "rubyEvalsAsync", + proc do + context.eval_async("(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_async("outer") } + end + assert_includes err.message, "nested async call" + assert_equal 2, context.eval("1 + 1") + end + def test_eval_async_delayed_task context = MiniRacer::Context.new result = context.eval_async(<<~JS) From 9f79bb889c8b3b7c9a7db5f955a9bd07f4340d05 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 11 Aug 2026 09:28:36 +0000 Subject: [PATCH 3/3] Rename call_async and eval_async to call_await and eval_await The methods block until the promise settles, so await describes what they do better than async. --- CHANGELOG | 2 +- README.md | 16 ++-- .../mini_racer_extension.c | 12 +-- ext/mini_racer_extension/mini_racer_v8.cc | 4 +- ext/mini_racer_extension/mini_racer_v8.h | 4 +- lib/mini_racer/shared.rb | 8 +- test/async_test.rb | 82 ++++++++++--------- test/single_threaded_test.rb | 8 +- 8 files changed, 69 insertions(+), 67 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 67c533d3..a08e5f17 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,5 @@ - Unreleased - - Add `Context#call_async` and `Context#eval_async`: like `call`/`eval` but block until a returned Promise settles and return the settled value; rejections raise `MiniRacer::RuntimeError` + - 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 99455b7d..561fef85 100644 --- a/README.md +++ b/README.md @@ -348,34 +348,34 @@ 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_async and eval_async +### Promises: call_await and eval_await -`call_async` and `eval_async` work like `call` and `eval`, but when the result is a +`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_async("f", 21) +context.call_await("f", 21) # => 42 -context.eval_async("(async () => 6 * 7)()") +context.eval_await("(async () => 6 * 7)()") # => 42 context.eval("async function boom() { throw new Error('kaboom') }") -context.call_async("boom") +context.call_await("boom") # => raises MiniRacer::RuntimeError (Error: kaboom) ``` -Non-Promise results pass through unchanged, so `call_async` is a drop-in -superset of `call` (same for `eval_async`/`eval`). +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_async` or `eval_async` recursively on the same context from an +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. diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index fe39d3f7..b7515e1d 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -810,9 +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_async); + 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_async); + 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); @@ -1686,7 +1686,7 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self) return context_call_common(argc, argv, self, 'C'); } -static VALUE context_call_async(int argc, VALUE *argv, VALUE self) +static VALUE context_call_await(int argc, VALUE *argv, VALUE self) { return context_call_common(argc, argv, self, 'D'); } @@ -1724,7 +1724,7 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self) return context_eval_common(argc, argv, self, 'E'); } -static VALUE context_eval_async(int argc, VALUE *argv, VALUE self) +static VALUE context_eval_await(int argc, VALUE *argv, VALUE self) { return context_eval_common(argc, argv, self, 'F'); } @@ -2169,9 +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_async", context_call_async, -1); + rb_define_method(c, "call_await", context_call_await, -1); rb_define_method(c, "eval", context_eval, -1); - rb_define_method(c, "eval_async", context_eval_async, -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 f4885fe2..4ff0c617 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -729,7 +729,7 @@ 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_async(State *pst, const uint8_t *p, size_t n) +extern "C" void v8_call_await(State *pst, const uint8_t *p, size_t n) { v8_call_impl(pst, p, n, true); } @@ -797,7 +797,7 @@ 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_async(State *pst, const uint8_t *p, size_t n) +extern "C" void v8_eval_await(State *pst, const uint8_t *p, size_t n) { v8_eval_impl(pst, p, n, true); } diff --git a/ext/mini_racer_extension/mini_racer_v8.h b/ext/mini_racer_extension/mini_racer_v8.h index b2ea7bed..3d34e9f6 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -39,9 +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_async(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_async(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 3ac3364d..0fc8e8ec 100644 --- a/lib/mini_racer/shared.rb +++ b/lib/mini_racer/shared.rb @@ -188,12 +188,12 @@ def call(function_name, *arguments) ensure_gc_thread if @ensure_gc_after_idle end - def eval_async(*) - raise MiniRacer::Error, "eval_async is not supported on TruffleRuby" + def eval_await(*) + raise MiniRacer::Error, "eval_await is not supported on TruffleRuby" end - def call_async(*) - raise MiniRacer::Error, "call_async is not supported on TruffleRuby" + def call_await(*) + raise MiniRacer::Error, "call_await is not supported on TruffleRuby" end def dispose diff --git a/test/async_test.rb b/test/async_test.rb index d5ed1193..d8d6d4a1 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -4,24 +4,24 @@ class MiniRacerAsyncTest < Minitest::Test def setup if RUBY_ENGINE == "truffleruby" - skip "TruffleRuby does not implement call_async/eval_async" + skip "TruffleRuby does not implement call_await/eval_await" end end - def test_call_async_returns_settled_value + 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_async("f", 21) + assert_equal 42, context.call_await("f", 21) end - def test_call_async_passes_arguments_exactly + def test_call_await_passes_arguments_exactly context = MiniRacer::Context.new context.eval("async function count() { return arguments.length }") - assert_equal 3, context.call_async("count", 1, 2, 3) - assert_equal 0, context.call_async("count") + assert_equal 3, context.call_await("count", 1, 2, 3) + assert_equal 0, context.call_await("count") end - def test_call_async_microtask_chain + def test_call_await_microtask_chain context = MiniRacer::Context.new context.eval(<<~JS) async function chain() { @@ -33,57 +33,57 @@ def test_call_async_microtask_chain return n; } JS - assert_equal 100, context.call_async("chain") + assert_equal 100, context.call_await("chain") end - def test_call_async_non_promise_passthrough + def test_call_await_non_promise_passthrough context = MiniRacer::Context.new context.eval("function sync(x) { return x + 1 }") - assert_equal 42, context.call_async("sync", 41) + assert_equal 42, context.call_await("sync", 41) end - def test_call_async_rejection_raises_runtime_error + 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_async("boom") } + err = assert_raises(MiniRacer::RuntimeError) { context.call_await("boom") } assert_includes err.message, "kaboom" end - def test_call_async_rejection_with_non_error_value + 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_async("nope") } + assert_raises(MiniRacer::RuntimeError) { context.call_await("nope") } end - def test_call_async_non_existing_function + def test_call_await_non_existing_function context = MiniRacer::Context.new - assert_raises(MiniRacer::RuntimeError) { context.call_async("missing") } + assert_raises(MiniRacer::RuntimeError) { context.call_await("missing") } end - def test_eval_async_top_level_promise + def test_eval_await_top_level_promise context = MiniRacer::Context.new result = - context.eval_async( + context.eval_await( "(async () => { await Promise.resolve(); return 6 * 7 })()" ) assert_equal 42, result end - def test_eval_async_non_promise + def test_eval_await_non_promise context = MiniRacer::Context.new - assert_equal 2, context.eval_async("1 + 1") + assert_equal 2, context.eval_await("1 + 1") end - def test_eval_async_filename + def test_eval_await_filename context = MiniRacer::Context.new err = assert_raises(MiniRacer::RuntimeError) do - context.eval_async("Promise.reject(new Error('x'))", filename: "foo.js") + context.eval_await("Promise.reject(new Error('x'))", filename: "foo.js") end assert_match(/foo\.js/, err.backtrace[0]) end - def test_call_async_ruby_callback_in_awaited_chain + def test_call_await_ruby_callback_in_awaited_chain context = MiniRacer::Context.new context.attach("rubyAdd", proc { |a, b| a + b }) context.eval(<<~JS) @@ -92,10 +92,10 @@ def test_call_async_ruby_callback_in_awaited_chain return rubyAdd(20, 22); } JS - assert_equal 42, context.call_async("viaRuby") + assert_equal 42, context.call_await("viaRuby") end - def test_call_async_ruby_callback_exception_propagates + def test_call_await_ruby_callback_exception_propagates context = MiniRacer::Context.new context.attach("rubyBoom", proc { raise "ruby boom" }) context.eval(<<~JS) @@ -104,13 +104,13 @@ def test_call_async_ruby_callback_exception_propagates return rubyBoom(); } JS - err = assert_raises(RuntimeError) { context.call_async("boomRuby") } + err = assert_raises(RuntimeError) { context.call_await("boomRuby") } assert_includes err.message, "ruby boom" end - def test_nested_call_async_fails_instead_of_deadlocking + def test_nested_call_await_fails_instead_of_deadlocking context = MiniRacer::Context.new - context.attach("rubyCallsAsync", proc { context.call_async("inner") }) + context.attach("rubyCallsAsync", proc { context.call_await("inner") }) context.eval(<<~JS) async function inner() { await Promise.resolve(); @@ -125,18 +125,20 @@ def test_nested_call_async_fails_instead_of_deadlocking err = assert_raises(MiniRacer::RuntimeError) do - Timeout.timeout(2) { context.call_async("outer") } + 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_async_fails_instead_of_deadlocking + def test_nested_eval_await_fails_instead_of_deadlocking context = MiniRacer::Context.new context.attach( "rubyEvalsAsync", proc do - context.eval_async("(async () => { await Promise.resolve(); return 42 })()") + context.eval_await( + "(async () => { await Promise.resolve(); return 42 })()" + ) end ) context.eval(<<~JS) @@ -148,15 +150,15 @@ def test_nested_eval_async_fails_instead_of_deadlocking err = assert_raises(MiniRacer::RuntimeError) do - Timeout.timeout(2) { context.call_async("outer") } + 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_async_delayed_task + def test_eval_await_delayed_task context = MiniRacer::Context.new - result = context.eval_async(<<~JS) + result = context.eval_await(<<~JS) (async () => { const i32 = new Int32Array(new SharedArrayBuffer(4)); return (await Atomics.waitAsync(i32, 0, 0, 20).value); @@ -169,7 +171,7 @@ 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_async("new Promise(() => {})") + context.eval_await("new Promise(() => {})") end elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start assert_operator elapsed, :<, 5 @@ -184,7 +186,7 @@ def test_never_settling_promise_interrupted_by_stop context.stop end assert_raises(MiniRacer::ScriptTerminatedError) do - context.eval_async("new Promise(() => {})") + context.eval_await("new Promise(() => {})") end stopper.join assert_equal 2, context.eval("1 + 1") @@ -194,7 +196,7 @@ def test_never_settling_promise_interrupted_by_thread_kill context = MiniRacer::Context.new thread = Thread.new do - context.eval_async("new Promise(() => {})") + context.eval_await("new Promise(() => {})") rescue MiniRacer::ScriptTerminatedError nil end @@ -210,11 +212,11 @@ def test_call_sync_does_not_await assert_equal({}, context.call("f")) end - def test_dispose_after_call_async + def test_dispose_after_call_await context = MiniRacer::Context.new context.eval("async function f() { return 1 }") - assert_equal 1, context.call_async("f") + assert_equal 1, context.call_await("f") context.dispose - assert_raises(MiniRacer::ContextDisposedError) { context.call_async("f") } + assert_raises(MiniRacer::ContextDisposedError) { context.call_await("f") } end end diff --git a/test/single_threaded_test.rb b/test/single_threaded_test.rb index 86faa3b5..60ac7aab 100644 --- a/test/single_threaded_test.rb +++ b/test/single_threaded_test.rb @@ -51,16 +51,16 @@ def test_ruby_callback_from_javascript RUBY end - def test_call_async_and_eval_async + 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_async("f", 21) == 42 - raise "bad async eval" unless context.eval_async("(async () => 6 * 7)()") == 42 + 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_async("new Promise(() => {})") + context.eval_await("new Promise(() => {})") raise "expected termination" rescue MiniRacer::ScriptTerminatedError end