Skip to content

Commit d8322d2

Browse files
committed
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.
1 parent 7335c7f commit d8322d2

9 files changed

Lines changed: 348 additions & 15 deletions

File tree

CHANGELOG

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
- Unreleased
2+
- 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`
23
- 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
34

45
- 0.21.4 - 24-06-2026

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,33 @@ Performance is slightly better than running `context.eval("hello('George')")` si
348348
* compilation of eval'd string is avoided
349349
* function arguments don't need to be converted to JSON
350350

351+
### Promises: call_async and eval_async
352+
353+
`call_async` and `eval_async` work like `call` and `eval`, but when the result is a
354+
Promise they block until it settles and return the settled value. A rejected
355+
promise raises `MiniRacer::RuntimeError`, just like a synchronous `throw`:
356+
357+
```ruby
358+
context = MiniRacer::Context.new
359+
context.eval("async function f(x) { await Promise.resolve(); return x * 2 }")
360+
context.call_async("f", 21)
361+
# => 42
362+
363+
context.eval_async("(async () => 6 * 7)()")
364+
# => 42
365+
366+
context.eval("async function boom() { throw new Error('kaboom') }")
367+
context.call_async("boom")
368+
# => raises MiniRacer::RuntimeError (Error: kaboom)
369+
```
370+
371+
Non-Promise results pass through unchanged, so `call_async` is a drop-in
372+
superset of `call` (same for `eval_async`/`eval`).
373+
374+
A promise that never settles blocks forever, just like an infinite loop. The
375+
`timeout:` option and `Context#stop` both interrupt it, raising
376+
`MiniRacer::ScriptTerminatedError`.
377+
351378
### Microtask checkpoints
352379

353380
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":

ext/mini_racer_extension/mini_racer_extension.c

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,9 @@ static void dispatch1(Context *c, const uint8_t *p, size_t n)
810810
switch (*p) {
811811
case 'A': return v8_attach(c->pst, p+1, n-1);
812812
case 'C': return v8_timedwait(c, p+1, n-1, v8_call);
813+
case 'D': return v8_timedwait(c, p+1, n-1, v8_call_async);
813814
case 'E': return v8_timedwait(c, p+1, n-1, v8_eval);
815+
case 'F': return v8_timedwait(c, p+1, n-1, v8_eval_async);
814816
case 'H': return v8_heap_snapshot(c->pst);
815817
case 'M': return v8_perform_microtask_checkpoint(c->pst);
816818
case 'P': return v8_pump_message_loop(c->pst);
@@ -888,7 +890,8 @@ void v8_dispatch(Context *c)
888890
pthread_mutex_unlock(&c->mtx);
889891
}
890892

891-
// only called when inside v8_call, v8_eval, or v8_pump_message_loop
893+
// only called when inside v8_call, v8_eval (and their async variants),
894+
// or v8_pump_message_loop
892895
void v8_roundtrip(Context *c, const uint8_t **p, size_t *n)
893896
{
894897
pthread_mutex_lock(&c->mtx);
@@ -1654,7 +1657,7 @@ static VALUE context_stop(VALUE self)
16541657
return Qnil;
16551658
}
16561659

1657-
static VALUE context_call(int argc, VALUE *argv, VALUE self)
1660+
static VALUE context_call_common(int argc, VALUE *argv, VALUE self, char op)
16581661
{
16591662
VALUE name, args;
16601663
VALUE a, e;
@@ -1665,8 +1668,8 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self)
16651668
rb_scan_args(argc, argv, "1*", &name, &args);
16661669
Check_Type(name, T_STRING);
16671670
rb_ary_unshift(args, name);
1668-
// request is (C)all, [name, args...] array
1669-
ser_init1(&s, 'C');
1671+
// request is (C)all or async (D) call, [name, args...] array
1672+
ser_init1(&s, op);
16701673
if (serialize(&s, args)) {
16711674
ser_reset(&s);
16721675
rb_raise(runtime_error, "Context.call: %s", s.err);
@@ -1678,7 +1681,17 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self)
16781681
return rb_ary_pop(a);
16791682
}
16801683

1681-
static VALUE context_eval(int argc, VALUE *argv, VALUE self)
1684+
static VALUE context_call(int argc, VALUE *argv, VALUE self)
1685+
{
1686+
return context_call_common(argc, argv, self, 'C');
1687+
}
1688+
1689+
static VALUE context_call_async(int argc, VALUE *argv, VALUE self)
1690+
{
1691+
return context_call_common(argc, argv, self, 'D');
1692+
}
1693+
1694+
static VALUE context_eval_common(int argc, VALUE *argv, VALUE self, char op)
16821695
{
16831696
VALUE a, e, source, filename, kwargs;
16841697
Context *c;
@@ -1693,8 +1706,8 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self)
16931706
if (NIL_P(filename))
16941707
filename = rb_str_new_cstr("<eval>");
16951708
Check_Type(filename, T_STRING);
1696-
// request is (E)val, [filename, source] array
1697-
ser_init1(&s, 'E');
1709+
// request is (E)val or async (F) eval, [filename, source] array
1710+
ser_init1(&s, op);
16981711
ser_array_begin(&s, 2);
16991712
add_string(&s, filename);
17001713
add_string(&s, source);
@@ -1706,6 +1719,16 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self)
17061719
return rb_ary_pop(a);
17071720
}
17081721

1722+
static VALUE context_eval(int argc, VALUE *argv, VALUE self)
1723+
{
1724+
return context_eval_common(argc, argv, self, 'E');
1725+
}
1726+
1727+
static VALUE context_eval_async(int argc, VALUE *argv, VALUE self)
1728+
{
1729+
return context_eval_common(argc, argv, self, 'F');
1730+
}
1731+
17091732
static VALUE context_heap_stats(VALUE self)
17101733
{
17111734
VALUE a, h, k, v;
@@ -2146,7 +2169,9 @@ void Init_mini_racer_extension(void)
21462169
rb_define_method(c, "dispose", context_dispose, 0);
21472170
rb_define_method(c, "stop", context_stop, 0);
21482171
rb_define_method(c, "call", context_call, -1);
2172+
rb_define_method(c, "call_async", context_call_async, -1);
21492173
rb_define_method(c, "eval", context_eval, -1);
2174+
rb_define_method(c, "eval_async", context_eval_async, -1);
21502175
rb_define_method(c, "heap_stats", context_heap_stats, 0);
21512176
rb_define_method(c, "heap_snapshot", context_heap_snapshot, 0);
21522177
rb_define_method(c, "perform_microtask_checkpoint", context_perform_microtask_checkpoint, 0);

ext/mini_racer_extension/mini_racer_v8.cc

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#include "v8-profiler.h"
33
#include "libplatform/libplatform.h"
44
#include "mini_racer_v8.h"
5+
#include <atomic>
56
#include <memory>
67
#include <vector>
78
#include <cassert>
@@ -91,6 +92,8 @@ struct State
9192
Context *ruby_context;
9293
int64_t max_memory;
9394
int err_reason;
95+
// TerminateExecution() while idle doesn't make IsExecutionTerminating() true
96+
std::atomic<bool> terminate_requested;
9497
bool verbose_exceptions;
9598
std::vector<Callback*> callbacks;
9699
std::unique_ptr<v8::ArrayBuffer::Allocator> allocator;
@@ -586,8 +589,35 @@ extern "C" void v8_attach(State *pst, const uint8_t *p, size_t n)
586589
reply_retry(st, err);
587590
}
588591

592+
// awaits |*result| if it's a promise; false means an exception is pending
593+
bool await_promise(State& st, v8::Local<v8::Value> *result)
594+
{
595+
if (!(*result)->IsPromise()) return true;
596+
auto promise = result->As<v8::Promise>();
597+
for (;;) {
598+
v8::MicrotasksScope::PerformCheckpoint(st.isolate);
599+
switch (promise->State()) {
600+
case v8::Promise::kFulfilled:
601+
*result = promise->Result();
602+
return true;
603+
case v8::Promise::kRejected:
604+
st.isolate->ThrowException(promise->Result());
605+
return false;
606+
case v8::Promise::kPending:
607+
break;
608+
}
609+
if (st.terminate_requested.load() || st.isolate->IsExecutionTerminating())
610+
return false;
611+
// blocks until the next task; v8_terminate_execution posts one to
612+
// end the wait on timeout/stop/interrupt
613+
v8::platform::PumpMessageLoop(
614+
platform, st.isolate,
615+
v8::platform::MessageLoopBehavior::kWaitForWork);
616+
}
617+
}
618+
589619
// response is errback [result, err] array
590-
extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
620+
void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await)
591621
{
592622
State& st = *pst;
593623
v8::TryCatch try_catch(st.isolate);
@@ -645,11 +675,13 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
645675
auto maybe_result_v = function->Call(st.context, obj, args.size(), args.data());
646676
v8::Local<v8::Value> result_v;
647677
if (!maybe_result_v.ToLocal(&result_v)) goto fail;
678+
if (await && !await_promise(st, &result_v)) goto fail;
648679
result = sanitize(st, result_v);
649680
}
650681
cause = NO_ERROR;
651682
fail:
652-
if (st.isolate->IsExecutionTerminating()) {
683+
if (st.terminate_requested.exchange(false) ||
684+
st.isolate->IsExecutionTerminating()) {
653685
st.isolate->CancelTerminateExecution();
654686
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
655687
st.err_reason = NO_ERROR;
@@ -664,8 +696,18 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
664696
}
665697
}
666698

699+
extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
700+
{
701+
v8_call_impl(pst, p, n, false);
702+
}
703+
704+
extern "C" void v8_call_async(State *pst, const uint8_t *p, size_t n)
705+
{
706+
v8_call_impl(pst, p, n, true);
707+
}
708+
667709
// response is errback [result, err] array
668-
extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
710+
void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await)
669711
{
670712
State& st = *pst;
671713
v8::TryCatch try_catch(st.isolate);
@@ -694,11 +736,13 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
694736
cause = RUNTIME_ERROR;
695737
auto maybe_result_v = script->Run(st.context);
696738
if (!maybe_result_v.ToLocal(&result_v)) goto fail;
739+
if (await && !await_promise(st, &result_v)) goto fail;
697740
result = sanitize(st, result_v);
698741
}
699742
cause = NO_ERROR;
700743
fail:
701-
if (st.isolate->IsExecutionTerminating()) {
744+
if (st.terminate_requested.exchange(false) ||
745+
st.isolate->IsExecutionTerminating()) {
702746
st.isolate->CancelTerminateExecution();
703747
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
704748
st.err_reason = NO_ERROR;
@@ -713,6 +757,16 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
713757
}
714758
}
715759

760+
extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
761+
{
762+
v8_eval_impl(pst, p, n, false);
763+
}
764+
765+
extern "C" void v8_eval_async(State *pst, const uint8_t *p, size_t n)
766+
{
767+
v8_eval_impl(pst, p, n, true);
768+
}
769+
716770
extern "C" void v8_heap_stats(State *pst)
717771
{
718772
State& st = *pst;
@@ -800,7 +854,8 @@ extern "C" void v8_pump_message_loop(State *pst)
800854
if (try_catch.HasCaught()) goto fail;
801855
}
802856
fail:
803-
if (st.isolate->IsExecutionTerminating()) {
857+
if (st.terminate_requested.exchange(false) ||
858+
st.isolate->IsExecutionTerminating()) {
804859
st.isolate->CancelTerminateExecution();
805860
st.err_reason = NO_ERROR;
806861
}
@@ -914,7 +969,8 @@ extern "C" void v8_snapshot(State *pst, const uint8_t *p, size_t n)
914969
}
915970
cause = NO_ERROR;
916971
fail:
917-
if (st.isolate->IsExecutionTerminating()) {
972+
if (st.terminate_requested.exchange(false) ||
973+
st.isolate->IsExecutionTerminating()) {
918974
st.isolate->CancelTerminateExecution();
919975
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
920976
st.err_reason = NO_ERROR;
@@ -984,7 +1040,8 @@ extern "C" void v8_warmup(State *pst, const uint8_t *p, size_t n)
9841040
}
9851041
cause = NO_ERROR;
9861042
fail:
987-
if (st.isolate->IsExecutionTerminating()) {
1043+
if (st.terminate_requested.exchange(false) ||
1044+
st.isolate->IsExecutionTerminating()) {
9881045
st.isolate->CancelTerminateExecution();
9891046
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
9901047
st.err_reason = NO_ERROR;
@@ -1008,17 +1065,27 @@ extern "C" void v8_low_memory_notification(State *pst)
10081065
pst->isolate->LowMemoryNotification();
10091066
}
10101067

1011-
// called from ruby thread
1068+
struct WakeupTask : public v8::Task
1069+
{
1070+
void Run() final {}
1071+
};
1072+
1073+
// called from ruby or watchdog thread
10121074
extern "C" void v8_terminate_execution(State *pst)
10131075
{
1076+
pst->terminate_requested.store(true);
10141077
pst->isolate->TerminateExecution();
1078+
// wake await_promise's message loop pump
1079+
platform->GetForegroundTaskRunner(pst->isolate)
1080+
->PostTask(std::make_unique<WakeupTask>());
10151081
}
10161082

10171083
// called from ruby thread
10181084
extern "C" void v8_cancel_terminate_execution(State *pst)
10191085
{
10201086
// TerminateExecution can race with V8 completing and queue a termination
10211087
// for the next entry without IsExecutionTerminating() becoming true.
1088+
pst->terminate_requested.store(false);
10221089
pst->isolate->CancelTerminateExecution();
10231090
}
10241091

ext/mini_racer_extension/mini_racer_v8.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ struct State *v8_thread_init(struct Context *c, const uint8_t *snapshot_buf,
3939
int verbose_exceptions); // calls v8_thread_main
4040
void v8_attach(struct State *pst, const uint8_t *p, size_t n);
4141
void v8_call(struct State *pst, const uint8_t *p, size_t n);
42+
void v8_call_async(struct State *pst, const uint8_t *p, size_t n);
4243
void v8_eval(struct State *pst, const uint8_t *p, size_t n);
44+
void v8_eval_async(struct State *pst, const uint8_t *p, size_t n);
4345
void v8_heap_stats(struct State *pst);
4446
void v8_heap_snapshot(struct State *pst);
4547
void v8_perform_microtask_checkpoint(struct State *pst);

lib/mini_racer/shared.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ def call(function_name, *arguments)
188188
ensure_gc_thread if @ensure_gc_after_idle
189189
end
190190

191+
def eval_async(*)
192+
raise MiniRacer::Error, "eval_async is not supported on TruffleRuby"
193+
end
194+
195+
def call_async(*)
196+
raise MiniRacer::Error, "call_async is not supported on TruffleRuby"
197+
end
198+
191199
def dispose
192200
return if @disposed
193201
isolate_mutex.synchronize do

0 commit comments

Comments
 (0)