Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
- Unreleased
- Add `Context#call_await` and `Context#eval_await`: like `call`/`eval` but block until a returned Promise settles and return the settled value; rejections raise `MiniRacer::RuntimeError`
- Fix a race introduced in 0.21.4 where a request sent right after a nested dispatch (e.g. `perform_microtask_checkpoint` or a nested `call` from an attached callback) could be dropped, deadlocking the context

- 0.21.4 - 24-06-2026
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,38 @@ Performance is slightly better than running `context.eval("hello('George')")` si
* compilation of eval'd string is avoided
* function arguments don't need to be converted to JSON

### Promises: call_await and eval_await

`call_await` and `eval_await` work like `call` and `eval`, but when the result is a
Promise they block until it settles and return the settled value. A rejected
promise raises `MiniRacer::RuntimeError`, just like a synchronous `throw`:

```ruby
context = MiniRacer::Context.new
context.eval("async function f(x) { await Promise.resolve(); return x * 2 }")
context.call_await("f", 21)
# => 42

context.eval_await("(async () => 6 * 7)()")
# => 42

context.eval("async function boom() { throw new Error('kaboom') }")
context.call_await("boom")
# => raises MiniRacer::RuntimeError (Error: kaboom)
```

Non-Promise results pass through unchanged, so `call_await` is a drop-in
superset of `call` (same for `eval_await`/`eval`).

A promise that never settles blocks forever, just like an infinite loop. The
`timeout:` option and `Context#stop` both interrupt it, raising
`MiniRacer::ScriptTerminatedError`.

Calling `call_await` or `eval_await` recursively on the same context from an
attached Ruby callback is not supported and raises `MiniRacer::RuntimeError`.
V8 cannot run the nested microtask checkpoint needed to settle such a call.
Synchronous nested `call` and `eval` remain supported.

### Microtask checkpoints

V8 drains its microtask queue (e.g. callbacks queued via `Promise.resolve().then(...)`) automatically when script execution returns to the embedder, so most code "just works":
Expand Down
39 changes: 32 additions & 7 deletions ext/mini_racer_extension/mini_racer_extension.c
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,9 @@ static void dispatch1(Context *c, const uint8_t *p, size_t n)
switch (*p) {
case 'A': return v8_attach(c->pst, p+1, n-1);
case 'C': return v8_timedwait(c, p+1, n-1, v8_call);
case 'D': return v8_timedwait(c, p+1, n-1, v8_call_await);
case 'E': return v8_timedwait(c, p+1, n-1, v8_eval);
case 'F': return v8_timedwait(c, p+1, n-1, v8_eval_await);
case 'H': return v8_heap_snapshot(c->pst);
case 'M': return v8_perform_microtask_checkpoint(c->pst);
case 'P': return v8_pump_message_loop(c->pst);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -1678,7 +1681,17 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self)
return rb_ary_pop(a);
}

static VALUE context_eval(int argc, VALUE *argv, VALUE self)
static VALUE context_call(int argc, VALUE *argv, VALUE self)
{
return context_call_common(argc, argv, self, 'C');
}

static VALUE context_call_await(int argc, VALUE *argv, VALUE self)
{
return context_call_common(argc, argv, self, 'D');
}

static VALUE context_eval_common(int argc, VALUE *argv, VALUE self, char op)
{
VALUE a, e, source, filename, kwargs;
Context *c;
Expand All @@ -1693,8 +1706,8 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self)
if (NIL_P(filename))
filename = rb_str_new_cstr("<eval>");
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);
Expand All @@ -1706,6 +1719,16 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self)
return rb_ary_pop(a);
}

static VALUE context_eval(int argc, VALUE *argv, VALUE self)
{
return context_eval_common(argc, argv, self, 'E');
}

static VALUE context_eval_await(int argc, VALUE *argv, VALUE self)
{
return context_eval_common(argc, argv, self, 'F');
}

static VALUE context_heap_stats(VALUE self)
{
VALUE a, h, k, v;
Expand Down Expand Up @@ -2146,7 +2169,9 @@ void Init_mini_racer_extension(void)
rb_define_method(c, "dispose", context_dispose, 0);
rb_define_method(c, "stop", context_stop, 0);
rb_define_method(c, "call", context_call, -1);
rb_define_method(c, "call_await", context_call_await, -1);
rb_define_method(c, "eval", context_eval, -1);
rb_define_method(c, "eval_await", context_eval_await, -1);
rb_define_method(c, "heap_stats", context_heap_stats, 0);
rb_define_method(c, "heap_snapshot", context_heap_snapshot, 0);
rb_define_method(c, "perform_microtask_checkpoint", context_perform_microtask_checkpoint, 0);
Expand Down
118 changes: 110 additions & 8 deletions ext/mini_racer_extension/mini_racer_v8.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include "v8-profiler.h"
#include "libplatform/libplatform.h"
#include "mini_racer_v8.h"
#include <atomic>
#include <memory>
#include <vector>
#include <cassert>
Expand Down Expand Up @@ -91,6 +92,11 @@ struct State
Context *ruby_context;
int64_t max_memory;
int err_reason;
// TerminateExecution() while idle doesn't make IsExecutionTerminating() true
std::atomic<bool> 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<Callback*> callbacks;
std::unique_ptr<v8::ArrayBuffer::Allocator> allocator;
Expand Down Expand Up @@ -586,8 +592,53 @@ extern "C" void v8_attach(State *pst, const uint8_t *p, size_t n)
reply_retry(st, err);
}

struct JavascriptCallScope
{
int& depth;

explicit JavascriptCallScope(int& depth) : depth(depth) { depth++; }
~JavascriptCallScope() { depth--; }
};

void throw_nested_async_call(State& st)
{
// V8 does not run microtask checkpoints recursively. A nested async call
// from an attached Ruby callback can therefore deadlock, so reject it
// before entering JavaScript.
auto message = v8::String::NewFromUtf8Literal(
st.isolate, "nested async calls are not supported");
st.isolate->ThrowException(v8::Exception::Error(message));
}

// awaits |*result| if it's a promise; false means an exception is pending
bool await_promise(State& st, v8::Local<v8::Value> *result)
{
if (!(*result)->IsPromise()) return true;
auto promise = result->As<v8::Promise>();
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);
Expand All @@ -598,6 +649,13 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
des.ReadHeader(st.context).Check();
v8::Local<v8::Value> 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<v8::Value> request_v;
if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail;
Expand Down Expand Up @@ -645,11 +703,13 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
auto maybe_result_v = function->Call(st.context, obj, args.size(), args.data());
v8::Local<v8::Value> 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;
Expand All @@ -664,8 +724,18 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
}
}

extern "C" void v8_call(State *pst, const uint8_t *p, size_t n)
{
v8_call_impl(pst, p, n, false);
}

extern "C" void v8_call_await(State *pst, const uint8_t *p, size_t n)
{
v8_call_impl(pst, p, n, true);
}

// response is errback [result, err] array
extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await)
{
State& st = *pst;
v8::TryCatch try_catch(st.isolate);
Expand All @@ -675,6 +745,13 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
des.ReadHeader(st.context).Check();
v8::Local<v8::Value> 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<v8::Value> request_v;
if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail;
Expand All @@ -694,11 +771,13 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
cause = RUNTIME_ERROR;
auto maybe_result_v = script->Run(st.context);
if (!maybe_result_v.ToLocal(&result_v)) goto fail;
if (await && !await_promise(st, &result_v)) goto fail;
result = sanitize(st, result_v);
}
cause = NO_ERROR;
fail:
if (st.isolate->IsExecutionTerminating()) {
if (st.terminate_requested.exchange(false) ||
st.isolate->IsExecutionTerminating()) {
st.isolate->CancelTerminateExecution();
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
st.err_reason = NO_ERROR;
Expand All @@ -713,6 +792,16 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
}
}

extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n)
{
v8_eval_impl(pst, p, n, false);
}

extern "C" void v8_eval_await(State *pst, const uint8_t *p, size_t n)
{
v8_eval_impl(pst, p, n, true);
}

extern "C" void v8_heap_stats(State *pst)
{
State& st = *pst;
Expand Down Expand Up @@ -800,7 +889,8 @@ extern "C" void v8_pump_message_loop(State *pst)
if (try_catch.HasCaught()) goto fail;
}
fail:
if (st.isolate->IsExecutionTerminating()) {
if (st.terminate_requested.exchange(false) ||
st.isolate->IsExecutionTerminating()) {
st.isolate->CancelTerminateExecution();
st.err_reason = NO_ERROR;
}
Expand Down Expand Up @@ -914,7 +1004,8 @@ extern "C" void v8_snapshot(State *pst, const uint8_t *p, size_t n)
}
cause = NO_ERROR;
fail:
if (st.isolate->IsExecutionTerminating()) {
if (st.terminate_requested.exchange(false) ||
st.isolate->IsExecutionTerminating()) {
st.isolate->CancelTerminateExecution();
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
st.err_reason = NO_ERROR;
Expand Down Expand Up @@ -984,7 +1075,8 @@ extern "C" void v8_warmup(State *pst, const uint8_t *p, size_t n)
}
cause = NO_ERROR;
fail:
if (st.isolate->IsExecutionTerminating()) {
if (st.terminate_requested.exchange(false) ||
st.isolate->IsExecutionTerminating()) {
st.isolate->CancelTerminateExecution();
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
st.err_reason = NO_ERROR;
Expand All @@ -1008,17 +1100,27 @@ 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<WakeupTask>());
}

// called from ruby thread
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();
}

Expand Down
2 changes: 2 additions & 0 deletions ext/mini_racer_extension/mini_racer_v8.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ struct State *v8_thread_init(struct Context *c, const uint8_t *snapshot_buf,
int verbose_exceptions); // calls v8_thread_main
void v8_attach(struct State *pst, const uint8_t *p, size_t n);
void v8_call(struct State *pst, const uint8_t *p, size_t n);
void v8_call_await(struct State *pst, const uint8_t *p, size_t n);
void v8_eval(struct State *pst, const uint8_t *p, size_t n);
void v8_eval_await(struct State *pst, const uint8_t *p, size_t n);
void v8_heap_stats(struct State *pst);
void v8_heap_snapshot(struct State *pst);
void v8_perform_microtask_checkpoint(struct State *pst);
Expand Down
8 changes: 8 additions & 0 deletions lib/mini_racer/shared.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ def call(function_name, *arguments)
ensure_gc_thread if @ensure_gc_after_idle
end

def eval_await(*)
raise MiniRacer::Error, "eval_await is not supported on TruffleRuby"
end

def call_await(*)
raise MiniRacer::Error, "call_await is not supported on TruffleRuby"
end

def dispose
return if @disposed
isolate_mutex.synchronize do
Expand Down
Loading