From bb500eb2e440f408403ed82bcda9f13941cc07e6 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 10 Aug 2026 17:12:26 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 From f7d1e1bb33379266b2cded2a5b4dfab166246d19 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 12 Aug 2026 13:55:14 +1000 Subject: [PATCH 04/10] FIX: preserve timeout across nested sync calls Keep the outer watchdog active when a synchronous context call is made from an async callback, so non-settling promises still time out and the context remains usable afterward. --- .../mini_racer_extension.c | 14 ++++++++++--- test/async_test.rb | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index b7515e1d..4c6e5369 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -152,7 +152,8 @@ typedef struct Context struct { pthread_mutex_t mtx; pthread_cond_t cv; - int cancel; + int active; // dispatch thread only + int cancel; // protected by |mtx| } wd; // watchdog Barrier early_init, late_init; } Context; @@ -786,8 +787,14 @@ static void v8_timedwait(Context *c, const uint8_t *p, size_t n, pthread_t thr; int r; - r = -1; - if (c->timeout > 0 && (r = pthread_create(&thr, NULL, v8_watchdog, c))) { + if (c->timeout <= 0 || c->wd.active) { + func(c->pst, p, n); + return; + } + c->wd.active = 1; + r = pthread_create(&thr, NULL, v8_watchdog, c); + if (r) { + c->wd.active = 0; fprintf(stderr, "mini_racer: watchdog: pthread_create: %s\n", strerror(r)); fflush(stderr); } @@ -800,6 +807,7 @@ static void v8_timedwait(Context *c, const uint8_t *p, size_t n, pthread_mutex_unlock(&c->wd.mtx); pthread_join(thr, NULL); c->wd.cancel = 0; + c->wd.active = 0; } static void dispatch1(Context *c, const uint8_t *p, size_t n) diff --git a/test/async_test.rb b/test/async_test.rb index d8d6d4a1..03b3fd54 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -167,6 +167,27 @@ def test_eval_await_delayed_task assert_equal "timed-out", result end + def test_timeout_survives_nested_sync_call + context = MiniRacer::Context.new(timeout: 200) + context.attach("rubyCallsSync", proc { context.call("inner") }) + context.eval(<<~JS) + function inner() { + return 42; + } + + async function outer() { + await Promise.resolve(); + rubyCallsSync(); + return new Promise(() => {}); + } + JS + + assert_raises(MiniRacer::ScriptTerminatedError) do + Timeout.timeout(2) { context.call_await("outer") } + end + assert_equal 2, context.eval("1 + 1") + end + def test_never_settling_promise_hits_timeout context = MiniRacer::Context.new(timeout: 200) start = Process.clock_gettime(Process::CLOCK_MONOTONIC) From b74350b0f18552913d62765397ae1b97bcaa97ac Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 12 Aug 2026 15:04:28 +1000 Subject: [PATCH 05/10] FIX: preserve stop across nested message pumps Keep termination active when a callback pumps the message loop during an enclosing call or eval. This ensures execution still raises ScriptTerminatedError instead of consuming the stop request prematurely. --- ext/mini_racer_extension/mini_racer_v8.cc | 10 ++++++---- test/async_test.rb | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 4ff0c617..d77b485a 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -94,8 +94,8 @@ 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. + // Tracks reentrant call/eval dispatches so nested await calls can fail + // instead of deadlocking and nested pumps preserve outer termination. int javascript_call_depth; bool verbose_exceptions; std::vector callbacks; @@ -889,8 +889,10 @@ extern "C" void v8_pump_message_loop(State *pst) if (try_catch.HasCaught()) goto fail; } fail: - if (st.terminate_requested.exchange(false) || - st.isolate->IsExecutionTerminating()) { + // A nested pump must leave termination active for the enclosing call/eval. + if (!st.javascript_call_depth && + (st.terminate_requested.exchange(false) || + st.isolate->IsExecutionTerminating())) { st.isolate->CancelTerminateExecution(); st.err_reason = NO_ERROR; } diff --git a/test/async_test.rb b/test/async_test.rb index 03b3fd54..811afec2 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -199,6 +199,22 @@ def test_never_settling_promise_hits_timeout assert_equal 2, context.eval("1 + 1") end + def test_pump_message_loop_does_not_consume_stop + context = MiniRacer::Context.new + context.attach( + "stopAndPump", + proc do + context.stop + context.pump_message_loop + end + ) + + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval("stopAndPump(); 42") + end + assert_equal 2, context.eval("1 + 1") + end + def test_never_settling_promise_interrupted_by_stop context = MiniRacer::Context.new stopper = From 5bca50df1e74e85ddc17f47ceb94a1aea53812d1 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 12 Aug 2026 16:43:20 +1000 Subject: [PATCH 06/10] FIX: isolate watchdog termination requests Track watchdog and external termination independently so a late timeout cannot terminate the next evaluation. Only queue wakeup tasks while an await loop may be blocked, and preserve memory-limit failures during async evaluation. --- .../mini_racer_extension.c | 3 +- ext/mini_racer_extension/mini_racer_v8.cc | 64 ++++++++++++++----- ext/mini_racer_extension/mini_racer_v8.h | 4 +- test/async_test.rb | 58 +++++++++++++++++ 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 4c6e5369..10bb95e6 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -773,7 +773,7 @@ static void *v8_watchdog(void *arg) if (c->wd.cancel) break; if (deadline_exceeded(deadline)) { - v8_terminate_execution(c->pst); + v8_terminate_watchdog(c->pst); break; } } @@ -806,6 +806,7 @@ static void v8_timedwait(Context *c, const uint8_t *p, size_t n, pthread_cond_signal(&c->wd.cv); pthread_mutex_unlock(&c->wd.mtx); pthread_join(thr, NULL); + v8_cancel_watchdog_termination(c->pst); c->wd.cancel = 0; c->wd.active = 0; } diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index d77b485a..c73e7fba 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -69,6 +69,12 @@ struct Callback int32_t id; }; +enum : unsigned +{ + NON_WATCHDOG_TERMINATION = 1, + WATCHDOG_TERMINATION = 2, +}; + // NOTE: do *not* use thread_locals to store state. In single-threaded // mode, V8 runs on the same thread as Ruby and the Ruby runtime clobbers // thread-locals when it context-switches threads. Ruby 3.4.0 has a new @@ -93,7 +99,10 @@ struct State int64_t max_memory; int err_reason; // TerminateExecution() while idle doesn't make IsExecutionTerminating() true - std::atomic terminate_requested; + std::atomic terminate_requested; + // Armed before checking terminate_requested so a concurrent terminator + // either gets observed or posts a task. Uses sequential consistency. + std::atomic waiting_for_task; // Tracks reentrant call/eval dispatches so nested await calls can fail // instead of deadlocking and nested pumps preserve outer termination. int javascript_call_depth; @@ -422,6 +431,7 @@ void v8_gc_callback(v8::Isolate*, v8::GCType, v8::GCCallbackFlags, void *data) int64_t used_heap_size = static_cast(s.used_heap_size()); if (used_heap_size > st.max_memory) { st.err_reason = MEMORY_ERROR; + st.terminate_requested.fetch_or(NON_WATCHDOG_TERMINATION); st.isolate->TerminateExecution(); } } @@ -627,13 +637,17 @@ bool await_promise(State& st, v8::Local *result) case v8::Promise::kPending: break; } - if (st.terminate_requested.load() || st.isolate->IsExecutionTerminating()) + st.waiting_for_task.store(true); + if (st.terminate_requested.load() || st.isolate->IsExecutionTerminating()) { + st.waiting_for_task.store(false); return false; - // blocks until the next task; v8_terminate_execution posts one to - // end the wait on timeout/stop/interrupt + } + // blocks until the next task; a concurrent v8_terminate_execution + // observes waiting_for_task and posts one to end the wait v8::platform::PumpMessageLoop( platform, st.isolate, v8::platform::MessageLoopBehavior::kWaitForWork); + st.waiting_for_task.store(false); } } @@ -708,7 +722,7 @@ void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(false) || + if (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; @@ -776,7 +790,7 @@ void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(false) || + if (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; @@ -891,7 +905,7 @@ extern "C" void v8_pump_message_loop(State *pst) fail: // A nested pump must leave termination active for the enclosing call/eval. if (!st.javascript_call_depth && - (st.terminate_requested.exchange(false) || + (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating())) { st.isolate->CancelTerminateExecution(); st.err_reason = NO_ERROR; @@ -1006,7 +1020,7 @@ extern "C" void v8_snapshot(State *pst, const uint8_t *p, size_t n) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(false) || + if (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; @@ -1077,7 +1091,7 @@ extern "C" void v8_warmup(State *pst, const uint8_t *p, size_t n) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(false) || + if (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; @@ -1108,13 +1122,33 @@ struct WakeupTask : public v8::Task }; // called from ruby or watchdog thread -extern "C" void v8_terminate_execution(State *pst) +void request_termination(State *pst, unsigned request) { - pst->terminate_requested.store(true); + pst->terminate_requested.fetch_or(request); pst->isolate->TerminateExecution(); - // wake await_promise's message loop pump - platform->GetForegroundTaskRunner(pst->isolate) - ->PostTask(std::make_unique()); + // Wake await_promise if it may be blocked. A racing early return can leave + // one harmless no-op task queued. + if (pst->waiting_for_task.exchange(false)) { + platform->GetForegroundTaskRunner(pst->isolate) + ->PostTask(std::make_unique()); + } +} + +extern "C" void v8_terminate_execution(State *pst) +{ + request_termination(pst, NON_WATCHDOG_TERMINATION); +} + +extern "C" void v8_terminate_watchdog(State *pst) +{ + request_termination(pst, WATCHDOG_TERMINATION); +} + +extern "C" void v8_cancel_watchdog_termination(State *pst) +{ + unsigned requests = pst->terminate_requested.fetch_and(~WATCHDOG_TERMINATION); + if (requests == WATCHDOG_TERMINATION) + pst->isolate->CancelTerminateExecution(); } // called from ruby thread @@ -1122,7 +1156,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->terminate_requested.store(0); pst->isolate->CancelTerminateExecution(); } diff --git a/ext/mini_racer_extension/mini_racer_v8.h b/ext/mini_racer_extension/mini_racer_v8.h index 3d34e9f6..947404dc 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -49,7 +49,9 @@ void v8_pump_message_loop(struct State *pst); void v8_snapshot(struct State *pst, const uint8_t *p, size_t n); void v8_warmup(struct State *pst, const uint8_t *p, size_t n); void v8_low_memory_notification(struct State *pst); -void v8_terminate_execution(struct State *pst); // called from ruby or watchdog thread +void v8_terminate_execution(struct State *pst); // called from ruby thread +void v8_terminate_watchdog(struct State *pst); // called from watchdog thread +void v8_cancel_watchdog_termination(struct State *pst); // called from v8 thread void v8_cancel_terminate_execution(struct State *pst); // called from ruby thread void v8_single_threaded_enter(struct State *pst, struct Context *c, void (*f)(struct Context *c)); void v8_single_threaded_dispose(struct State *pst); diff --git a/test/async_test.rb b/test/async_test.rb index 811afec2..cb95e70d 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -215,6 +215,64 @@ def test_pump_message_loop_does_not_consume_stop assert_equal 2, context.eval("1 + 1") end + def test_sync_stop_does_not_leave_a_wakeup_task + context = MiniRacer::Context.new + context.attach("stopNow", proc { context.stop }) + + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval("stopNow(); 42") + end + assert_equal false, context.pump_message_loop + assert_equal 2, context.eval("1 + 1") + end + + def test_eval_await_preserves_max_memory_error + context = MiniRacer::Context.new(max_memory: 100_000) + forced_gc = false + context.attach( + "forceGc", + proc do + forced_gc = true + context.low_memory_notification + end + ) + + assert_raises(MiniRacer::V8OutOfMemoryError) do + Timeout.timeout(2) do + # Trigger GC from a microtask while eval_await owns the pending promise. + context.eval_await(<<~JS) + Promise.resolve().then(() => forceGc()); + new Promise(() => {}); + JS + end + end + assert forced_gc + assert_equal 2, context.eval("1 + 1") + end + + def test_late_watchdog_does_not_terminate_next_eval + context = MiniRacer::Context.new(timeout: 5) + context.eval("var values = []") + attempts = 0 + 200.times do + begin + context.eval(<<~JS) + values.push(...new Array(10000).fill(1)); + values.length; + JS + rescue MiniRacer::ScriptTerminatedError + attempts += 1 + raise if attempts > 100 + retry + end + end + + # Serializing this result outlives the watchdog after eval's final + # termination check. Its late timeout belongs to this eval, not the next. + assert_operator context.eval("values").length, :>=, 2_000_000 + assert_equal 2, context.eval("1 + 1") + end + def test_never_settling_promise_interrupted_by_stop context = MiniRacer::Context.new stopper = From 05d2f6ae615e2b53a3998c7fd6318f7285cf4ea8 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 12 Aug 2026 17:54:16 +1000 Subject: [PATCH 07/10] FIX: preserve termination across nested calls Keep stop, timeout, and out-of-memory requests pending after nested evals and calls so the enclosing execution still terminates. Clarify await errors and document that await APIs are unavailable on TruffleRuby. --- CHANGELOG | 1 + README.md | 2 + .../mini_racer_extension.c | 6 +- ext/mini_racer_extension/mini_racer_v8.cc | 63 +++++++--- lib/mini_racer/shared.rb | 4 +- test/async_test.rb | 109 +++++++++++++++--- 6 files changed, 143 insertions(+), 42 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a08e5f17..8aef723d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ - 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` + - Preserve timeout, stop, and out-of-memory termination while awaiting Promises and across nested callbacks; prevent late watchdog timeouts from affecting later evaluations - 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 561fef85..5e2faa31 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,8 @@ 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. +`call_await` and `eval_await` are not currently supported on TruffleRuby. + ### 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 10bb95e6..14dec602 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -899,7 +899,7 @@ void v8_dispatch(Context *c) pthread_mutex_unlock(&c->mtx); } -// only called when inside v8_call, v8_eval (and their async variants), +// only called when inside v8_call, v8_eval (and their await variants), // or v8_pump_message_loop void v8_roundtrip(Context *c, const uint8_t **p, size_t *n) { @@ -1677,7 +1677,7 @@ static VALUE context_call_common(int argc, VALUE *argv, VALUE self, char op) rb_scan_args(argc, argv, "1*", &name, &args); Check_Type(name, T_STRING); rb_ary_unshift(args, name); - // request is (C)all or async (D) call, [name, args...] array + // request is (C)all or (D) call_await, [name, args...] array ser_init1(&s, op); if (serialize(&s, args)) { ser_reset(&s); @@ -1715,7 +1715,7 @@ static VALUE context_eval_common(int argc, VALUE *argv, VALUE self, char op) if (NIL_P(filename)) filename = rb_str_new_cstr(""); Check_Type(filename, T_STRING); - // request is (E)val or async (F) eval, [filename, source] array + // request is (E)val or (F) eval_await, [filename, source] array ser_init1(&s, op); ser_array_begin(&s, 2); add_string(&s, filename); diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index c73e7fba..3f36350e 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -610,13 +610,13 @@ struct JavascriptCallScope ~JavascriptCallScope() { depth--; } }; -void throw_nested_async_call(State& st) +void throw_nested_await_call(State& st) { - // V8 does not run microtask checkpoints recursively. A nested async call + // V8 does not run microtask checkpoints recursively. A nested await 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, "nested call_await/eval_await is not supported"); st.isolate->ThrowException(v8::Exception::Error(message)); } @@ -651,6 +651,25 @@ bool await_promise(State& st, v8::Local *result) } } +// Nested calls report termination but leave it pending for the enclosing call. +// Outermost calls consume it as before. +bool suspend_termination(State& st, bool nested, int& cause) +{ + unsigned requests = nested ? st.terminate_requested.load() + : st.terminate_requested.exchange(0); + if (!requests && !st.isolate->IsExecutionTerminating()) return false; + st.isolate->CancelTerminateExecution(); + cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; + if (!nested) st.err_reason = NO_ERROR; + return nested; +} + +void restore_termination(State& st, bool suspended) +{ + if (suspended) + st.isolate->TerminateExecution(); +} + // response is errback [result, err] array void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) { @@ -663,10 +682,11 @@ 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 preserve_termination = false; bool nested = st.javascript_call_depth > 0; JavascriptCallScope call_scope(st.javascript_call_depth); if (await && nested) { - throw_nested_async_call(st); + throw_nested_await_call(st); cause = RUNTIME_ERROR; goto fail; } @@ -722,13 +742,11 @@ void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(0) || - st.isolate->IsExecutionTerminating()) { - st.isolate->CancelTerminateExecution(); - cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; - st.err_reason = NO_ERROR; + preserve_termination = suspend_termination(st, nested, cause); + if (bubble_up_ruby_exception(st, &try_catch)) { + restore_termination(st, preserve_termination); + return; } - if (bubble_up_ruby_exception(st, &try_catch)) return; if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; if (cause) result = v8::Undefined(st.isolate); auto err = to_error(st, &try_catch, cause); @@ -736,6 +754,7 @@ void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) assert(try_catch.HasCaught()); goto fail; // retry; can be termination exception } + restore_termination(st, preserve_termination); } extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) @@ -759,10 +778,11 @@ 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 preserve_termination = false; bool nested = st.javascript_call_depth > 0; JavascriptCallScope call_scope(st.javascript_call_depth); if (await && nested) { - throw_nested_async_call(st); + throw_nested_await_call(st); cause = RUNTIME_ERROR; goto fail; } @@ -790,13 +810,11 @@ void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) } cause = NO_ERROR; fail: - if (st.terminate_requested.exchange(0) || - st.isolate->IsExecutionTerminating()) { - st.isolate->CancelTerminateExecution(); - cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; - st.err_reason = NO_ERROR; + preserve_termination = suspend_termination(st, nested, cause); + if (bubble_up_ruby_exception(st, &try_catch)) { + restore_termination(st, preserve_termination); + return; } - if (bubble_up_ruby_exception(st, &try_catch)) return; if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; if (cause) result = v8::Undefined(st.isolate); auto err = to_error(st, &try_catch, cause); @@ -804,6 +822,7 @@ void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) assert(try_catch.HasCaught()); goto fail; // retry; can be termination exception } + restore_termination(st, preserve_termination); } extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) @@ -878,6 +897,7 @@ extern "C" void v8_perform_microtask_checkpoint(State *pst) // Leave any termination active so the enclosing v8_call/v8_eval frame // surfaces OOM (set by v8_gc_callback) or watchdog termination to Ruby. State& st = *pst; + JavascriptCallScope call_scope(st.javascript_call_depth); v8::TryCatch try_catch(st.isolate); try_catch.SetVerbose(st.verbose_exceptions); v8::HandleScope handle_scope(st.isolate); @@ -888,6 +908,8 @@ extern "C" void v8_perform_microtask_checkpoint(State *pst) extern "C" void v8_pump_message_loop(State *pst) { State& st = *pst; + bool nested = st.javascript_call_depth > 0; + JavascriptCallScope call_scope(st.javascript_call_depth); v8::TryCatch try_catch(st.isolate); try_catch.SetVerbose(st.verbose_exceptions); v8::HandleScope handle_scope(st.isolate); @@ -904,7 +926,7 @@ extern "C" void v8_pump_message_loop(State *pst) } fail: // A nested pump must leave termination active for the enclosing call/eval. - if (!st.javascript_call_depth && + if (!nested && (st.terminate_requested.exchange(0) || st.isolate->IsExecutionTerminating())) { st.isolate->CancelTerminateExecution(); @@ -1147,8 +1169,11 @@ extern "C" void v8_terminate_watchdog(State *pst) extern "C" void v8_cancel_watchdog_termination(State *pst) { unsigned requests = pst->terminate_requested.fetch_and(~WATCHDOG_TERMINATION); - if (requests == WATCHDOG_TERMINATION) + if (requests == WATCHDOG_TERMINATION) { pst->isolate->CancelTerminateExecution(); + if (pst->terminate_requested.load()) + pst->isolate->TerminateExecution(); + } } // called from ruby thread diff --git a/lib/mini_racer/shared.rb b/lib/mini_racer/shared.rb index 0fc8e8ec..757e3f33 100644 --- a/lib/mini_racer/shared.rb +++ b/lib/mini_racer/shared.rb @@ -188,11 +188,11 @@ def call(function_name, *arguments) ensure_gc_thread if @ensure_gc_after_idle end - def eval_await(*) + def eval_await(*, **) raise MiniRacer::Error, "eval_await is not supported on TruffleRuby" end - def call_await(*) + def call_await(*, **) raise MiniRacer::Error, "call_await is not supported on TruffleRuby" end diff --git a/test/async_test.rb b/test/async_test.rb index cb95e70d..60a9e6fa 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -127,7 +127,7 @@ def test_nested_call_await_fails_instead_of_deadlocking assert_raises(MiniRacer::RuntimeError) do Timeout.timeout(2) { context.call_await("outer") } end - assert_includes err.message, "nested async call" + assert_includes err.message, "nested call_await/eval_await" assert_equal 2, context.eval("1 + 1") end @@ -152,7 +152,7 @@ def test_nested_eval_await_fails_instead_of_deadlocking assert_raises(MiniRacer::RuntimeError) do Timeout.timeout(2) { context.call_await("outer") } end - assert_includes err.message, "nested async call" + assert_includes err.message, "nested call_await/eval_await" assert_equal 2, context.eval("1 + 1") end @@ -167,6 +167,65 @@ def test_eval_await_delayed_task assert_equal "timed-out", result end + def test_nested_sync_call_does_not_consume_stop + context = MiniRacer::Context.new + nested_terminated = false + context.eval("function inner() { return 1 }") + context.attach( + "stopAndCall", + proc do + context.stop + begin + context.call("inner") + rescue MiniRacer::ScriptTerminatedError + nested_terminated = true + end + nil + end + ) + + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval("stopAndCall(); 42") + end + assert nested_terminated + assert_equal 2, context.eval("1 + 1") + end + + def test_nested_sync_eval_does_not_consume_timeout + context = MiniRacer::Context.new(timeout: 100) + nested_terminated = false + context.attach( + "runSlowEval", + proc do + begin + context.eval(<<~JS) + (() => { + const start = Date.now(); + while (Date.now() - start < 400) {} + })(); + JS + rescue MiniRacer::ScriptTerminatedError + nested_terminated = true + end + nil + end + ) + + assert_raises(MiniRacer::ScriptTerminatedError) do + Timeout.timeout(2) do + context.eval(<<~JS) + runSlowEval(); + (() => { + const start = Date.now(); + while (Date.now() - start < 500) {} + })(); + JS + end + end + assert nested_terminated + assert_equal 2, context.eval("1 + 1") + end + def test_timeout_survives_nested_sync_call context = MiniRacer::Context.new(timeout: 200) context.attach("rubyCallsSync", proc { context.call("inner") }) @@ -199,6 +258,32 @@ def test_never_settling_promise_hits_timeout assert_equal 2, context.eval("1 + 1") end + def test_await_from_pumped_callback_fails_without_hanging + context = MiniRacer::Context.new + nested_error = nil + context.attach( + "nestedAwait", + proc do + context.eval_await("new Promise(() => {})") + rescue MiniRacer::RuntimeError => error + nested_error = error + end + ) + context.eval(<<~JS) + const i32 = new Int32Array(new SharedArrayBuffer(4)); + Atomics.waitAsync(i32, 0, 0, 20).value.then(() => nestedAwait()); + JS + + Timeout.timeout(2) do + until nested_error + context.pump_message_loop + sleep 0.01 + end + end + assert_includes nested_error.message, "nested call_await/eval_await" + assert_equal 2, context.eval("1 + 1") + end + def test_pump_message_loop_does_not_consume_stop context = MiniRacer::Context.new context.attach( @@ -251,25 +336,13 @@ def test_eval_await_preserves_max_memory_error end def test_late_watchdog_does_not_terminate_next_eval - context = MiniRacer::Context.new(timeout: 5) - context.eval("var values = []") - attempts = 0 - 200.times do - begin - context.eval(<<~JS) - values.push(...new Array(10000).fill(1)); - values.length; - JS - rescue MiniRacer::ScriptTerminatedError - attempts += 1 - raise if attempts > 100 - retry - end - end + value_size = 20_000_000 + snapshot = MiniRacer::Snapshot.new("var value = 'x'.repeat(#{value_size})") + context = MiniRacer::Context.new(timeout: 5, snapshot: snapshot) # Serializing this result outlives the watchdog after eval's final # termination check. Its late timeout belongs to this eval, not the next. - assert_operator context.eval("values").length, :>=, 2_000_000 + assert_equal value_size, context.eval("value").bytesize assert_equal 2, context.eval("1 + 1") end From 23e33f0af109da03bf4589ecbdd4afa75677692f Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 12 Aug 2026 08:22:57 +0000 Subject: [PATCH 08/10] Lint --- test/async_test.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/async_test.rb b/test/async_test.rb index 60a9e6fa..9752dcb2 100644 --- a/test/async_test.rb +++ b/test/async_test.rb @@ -211,16 +211,16 @@ def test_nested_sync_eval_does_not_consume_timeout end ) + source = <<~JS + runSlowEval(); + (() => { + const start = Date.now(); + while (Date.now() - start < 500) {} + })(); + JS + assert_raises(MiniRacer::ScriptTerminatedError) do - Timeout.timeout(2) do - context.eval(<<~JS) - runSlowEval(); - (() => { - const start = Date.now(); - while (Date.now() - start < 500) {} - })(); - JS - end + Timeout.timeout(2) { context.eval(source) } end assert nested_terminated assert_equal 2, context.eval("1 + 1") From 72871d1d61f83af09d7de93473239b52719ca47e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 12 Aug 2026 09:05:45 +0000 Subject: [PATCH 09/10] Trim the changelog to the fixes that affect released versions The nested message pump, the stray wakeup task and the lost out-of-memory errors were all bugs in the await work on this branch, so no released version ever had them and they don't belong in the release notes. - The nested-callback and late-timeout fixes do affect 0.21.4 and earlier, so they get a line each. --- CHANGELOG | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 8aef723d..60ad4d44 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ - 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` - - Preserve timeout, stop, and out-of-memory termination while awaiting Promises and across nested callbacks; prevent late watchdog timeouts from affecting later evaluations + - Fix a `call` or `eval` made from a Ruby callback taking the timeout or `stop` meant for the evaluation around it, which then kept running + - Fix a timeout that fires just after its own evaluation ends, where it would stop the next evaluation instead - 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 From d2cc0bb6d240f87c3d9539d7434d838c93f3b31c Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 12 Aug 2026 09:30:02 +0000 Subject: [PATCH 10/10] Bump the version to 0.22.0 `call_await` and `eval_await` are new API, so the minor version goes up rather than the patch. --- CHANGELOG | 2 +- lib/mini_racer/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 60ad4d44..2a4b8cd1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -- Unreleased +- 0.22.0 - 12-08-2026 - 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 `call` or `eval` made from a Ruby callback taking the timeout or `stop` meant for the evaluation around it, which then kept running - Fix a timeout that fires just after its own evaluation ends, where it would stop the next evaluation instead diff --git a/lib/mini_racer/version.rb b/lib/mini_racer/version.rb index 7cfa067a..32879b3d 100644 --- a/lib/mini_racer/version.rb +++ b/lib/mini_racer/version.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true module MiniRacer - VERSION = "0.21.4" + VERSION = "0.22.0" LIBV8_NODE_VERSION = "~> 24.12.0.1" end