diff --git a/CHANGELOG b/CHANGELOG index ec96172..2a4b8cd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,7 @@ -- 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 - Fix a race introduced in 0.21.4 where a request sent right after a nested dispatch (e.g. `perform_microtask_checkpoint` or a nested `call` from an attached callback) could be dropped, deadlocking the context - 0.21.4 - 24-06-2026 diff --git a/README.md b/README.md index 6c0b0b0..5e2faa3 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,40 @@ 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. + +`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 c13dbfe..14dec60 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; @@ -772,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; } } @@ -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); } @@ -799,7 +806,9 @@ 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; } static void dispatch1(Context *c, const uint8_t *p, size_t n) @@ -810,7 +819,9 @@ static void dispatch1(Context *c, const uint8_t *p, size_t n) switch (*p) { case 'A': return v8_attach(c->pst, p+1, n-1); case 'C': return v8_timedwait(c, p+1, n-1, v8_call); + case 'D': return v8_timedwait(c, p+1, n-1, v8_call_await); case 'E': return v8_timedwait(c, p+1, n-1, v8_eval); + case 'F': return v8_timedwait(c, p+1, n-1, v8_eval_await); case 'H': return v8_heap_snapshot(c->pst); case 'M': return v8_perform_microtask_checkpoint(c->pst); case 'P': return v8_pump_message_loop(c->pst); @@ -888,7 +899,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 await 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 +1666,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 +1677,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 (D) call_await, [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 +1690,17 @@ static VALUE context_call(int argc, VALUE *argv, VALUE self) return rb_ary_pop(a); } -static VALUE context_eval(int argc, VALUE *argv, VALUE self) +static VALUE context_call(int argc, VALUE *argv, VALUE self) +{ + return context_call_common(argc, argv, self, 'C'); +} + +static VALUE context_call_await(int argc, VALUE *argv, VALUE self) +{ + return context_call_common(argc, argv, self, 'D'); +} + +static VALUE context_eval_common(int argc, VALUE *argv, VALUE self, char op) { VALUE a, e, source, filename, kwargs; Context *c; @@ -1693,8 +1715,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 (F) eval_await, [filename, source] array + ser_init1(&s, op); ser_array_begin(&s, 2); add_string(&s, filename); add_string(&s, source); @@ -1706,6 +1728,16 @@ static VALUE context_eval(int argc, VALUE *argv, VALUE self) return rb_ary_pop(a); } +static VALUE context_eval(int argc, VALUE *argv, VALUE self) +{ + return context_eval_common(argc, argv, self, 'E'); +} + +static VALUE context_eval_await(int argc, VALUE *argv, VALUE self) +{ + return context_eval_common(argc, argv, self, 'F'); +} + static VALUE context_heap_stats(VALUE self) { VALUE a, h, k, v; @@ -2146,7 +2178,9 @@ void Init_mini_racer_extension(void) rb_define_method(c, "dispose", context_dispose, 0); rb_define_method(c, "stop", context_stop, 0); rb_define_method(c, "call", context_call, -1); + rb_define_method(c, "call_await", context_call_await, -1); rb_define_method(c, "eval", context_eval, -1); + rb_define_method(c, "eval_await", context_eval_await, -1); rb_define_method(c, "heap_stats", context_heap_stats, 0); rb_define_method(c, "heap_snapshot", context_heap_snapshot, 0); rb_define_method(c, "perform_microtask_checkpoint", context_perform_microtask_checkpoint, 0); diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 76551f9..3f36350 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 @@ -68,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 @@ -91,6 +98,14 @@ struct State Context *ruby_context; int64_t max_memory; int err_reason; + // TerminateExecution() while idle doesn't make IsExecutionTerminating() true + 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; bool verbose_exceptions; std::vector callbacks; std::unique_ptr allocator; @@ -416,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(); } } @@ -586,8 +602,76 @@ 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_await_call(State& st) +{ + // 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 call_await/eval_await is not supported"); + st.isolate->ThrowException(v8::Exception::Error(message)); +} + +// awaits |*result| if it's a promise; false means an exception is pending +bool await_promise(State& st, v8::Local *result) +{ + if (!(*result)->IsPromise()) return true; + auto promise = result->As(); + for (;;) { + v8::MicrotasksScope::PerformCheckpoint(st.isolate); + switch (promise->State()) { + case v8::Promise::kFulfilled: + *result = promise->Result(); + return true; + case v8::Promise::kRejected: + st.isolate->ThrowException(promise->Result()); + return false; + case v8::Promise::kPending: + break; + } + 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; 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); + } +} + +// 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 -extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) +void v8_call_impl(State *pst, const uint8_t *p, size_t n, bool await) { State& st = *pst; v8::TryCatch try_catch(st.isolate); @@ -598,6 +682,14 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) des.ReadHeader(st.context).Check(); v8::Local result; int cause = INTERNAL_ERROR; + bool preserve_termination = false; + bool nested = st.javascript_call_depth > 0; + JavascriptCallScope call_scope(st.javascript_call_depth); + if (await && nested) { + throw_nested_await_call(st); + cause = RUNTIME_ERROR; + goto fail; + } { v8::Local request_v; if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail; @@ -645,16 +737,16 @@ 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()) { - 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); @@ -662,10 +754,21 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) 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) +{ + v8_call_impl(pst, p, n, false); +} + +extern "C" void v8_call_await(State *pst, const uint8_t *p, size_t n) +{ + v8_call_impl(pst, p, n, true); } // response is errback [result, err] array -extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) +void v8_eval_impl(State *pst, const uint8_t *p, size_t n, bool await) { State& st = *pst; v8::TryCatch try_catch(st.isolate); @@ -675,6 +778,14 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) des.ReadHeader(st.context).Check(); v8::Local result; int cause = INTERNAL_ERROR; + bool preserve_termination = false; + bool nested = st.javascript_call_depth > 0; + JavascriptCallScope call_scope(st.javascript_call_depth); + if (await && nested) { + throw_nested_await_call(st); + cause = RUNTIME_ERROR; + goto fail; + } { v8::Local request_v; if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail; @@ -694,16 +805,16 @@ 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()) { - 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); @@ -711,6 +822,17 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) 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) +{ + 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) @@ -775,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); @@ -785,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); @@ -800,7 +925,10 @@ extern "C" void v8_pump_message_loop(State *pst) if (try_catch.HasCaught()) goto fail; } fail: - if (st.isolate->IsExecutionTerminating()) { + // A nested pump must leave termination active for the enclosing call/eval. + if (!nested && + (st.terminate_requested.exchange(0) || + st.isolate->IsExecutionTerminating())) { st.isolate->CancelTerminateExecution(); st.err_reason = NO_ERROR; } @@ -914,7 +1042,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(0) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -984,7 +1113,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(0) || + st.isolate->IsExecutionTerminating()) { st.isolate->CancelTerminateExecution(); cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; @@ -1008,10 +1138,42 @@ extern "C" void v8_low_memory_notification(State *pst) pst->isolate->LowMemoryNotification(); } -// called from ruby thread -extern "C" void v8_terminate_execution(State *pst) +struct WakeupTask : public v8::Task +{ + void Run() final {} +}; + +// called from ruby or watchdog thread +void request_termination(State *pst, unsigned request) { + pst->terminate_requested.fetch_or(request); pst->isolate->TerminateExecution(); + // 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(); + if (pst->terminate_requested.load()) + pst->isolate->TerminateExecution(); + } } // called from ruby thread @@ -1019,6 +1181,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(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 81394ce..947404d 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -39,7 +39,9 @@ struct State *v8_thread_init(struct Context *c, const uint8_t *snapshot_buf, int verbose_exceptions); // calls v8_thread_main void v8_attach(struct State *pst, const uint8_t *p, size_t n); void v8_call(struct State *pst, const uint8_t *p, size_t n); +void v8_call_await(struct State *pst, const uint8_t *p, size_t n); void v8_eval(struct State *pst, const uint8_t *p, size_t n); +void v8_eval_await(struct State *pst, const uint8_t *p, size_t n); void v8_heap_stats(struct State *pst); void v8_heap_snapshot(struct State *pst); void v8_perform_microtask_checkpoint(struct State *pst); @@ -47,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/lib/mini_racer/shared.rb b/lib/mini_racer/shared.rb index b66e59d..757e3f3 100644 --- a/lib/mini_racer/shared.rb +++ b/lib/mini_racer/shared.rb @@ -188,6 +188,14 @@ def call(function_name, *arguments) ensure_gc_thread if @ensure_gc_after_idle end + def eval_await(*, **) + raise MiniRacer::Error, "eval_await is not supported on TruffleRuby" + end + + def call_await(*, **) + raise MiniRacer::Error, "call_await is not supported on TruffleRuby" + end + def dispose return if @disposed isolate_mutex.synchronize do diff --git a/lib/mini_racer/version.rb b/lib/mini_racer/version.rb index 7cfa067..32879b3 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 diff --git a/test/async_test.rb b/test/async_test.rb new file mode 100644 index 0000000..9752dcb --- /dev/null +++ b/test/async_test.rb @@ -0,0 +1,390 @@ +require "test_helper" +require "timeout" + +class MiniRacerAsyncTest < Minitest::Test + def setup + if RUBY_ENGINE == "truffleruby" + skip "TruffleRuby does not implement call_await/eval_await" + end + end + + def test_call_await_returns_settled_value + context = MiniRacer::Context.new + context.eval("async function f(x) { return x * 2 }") + assert_equal 42, context.call_await("f", 21) + end + + def test_call_await_passes_arguments_exactly + context = MiniRacer::Context.new + context.eval("async function count() { return arguments.length }") + assert_equal 3, context.call_await("count", 1, 2, 3) + assert_equal 0, context.call_await("count") + end + + def test_call_await_microtask_chain + context = MiniRacer::Context.new + context.eval(<<~JS) + async function chain() { + let n = 0; + for (let i = 0; i < 100; i++) { + await Promise.resolve(); + n++; + } + return n; + } + JS + assert_equal 100, context.call_await("chain") + end + + def test_call_await_non_promise_passthrough + context = MiniRacer::Context.new + context.eval("function sync(x) { return x + 1 }") + assert_equal 42, context.call_await("sync", 41) + end + + def test_call_await_rejection_raises_runtime_error + context = MiniRacer::Context.new + context.eval("async function boom() { throw new Error('kaboom') }") + err = assert_raises(MiniRacer::RuntimeError) { context.call_await("boom") } + assert_includes err.message, "kaboom" + end + + def test_call_await_rejection_with_non_error_value + context = MiniRacer::Context.new + context.eval("function nope() { return Promise.reject('just a string') }") + assert_raises(MiniRacer::RuntimeError) { context.call_await("nope") } + end + + def test_call_await_non_existing_function + context = MiniRacer::Context.new + assert_raises(MiniRacer::RuntimeError) { context.call_await("missing") } + end + + def test_eval_await_top_level_promise + context = MiniRacer::Context.new + result = + context.eval_await( + "(async () => { await Promise.resolve(); return 6 * 7 })()" + ) + assert_equal 42, result + end + + def test_eval_await_non_promise + context = MiniRacer::Context.new + assert_equal 2, context.eval_await("1 + 1") + end + + def test_eval_await_filename + context = MiniRacer::Context.new + err = + assert_raises(MiniRacer::RuntimeError) do + context.eval_await("Promise.reject(new Error('x'))", filename: "foo.js") + end + assert_match(/foo\.js/, err.backtrace[0]) + end + + def test_call_await_ruby_callback_in_awaited_chain + context = MiniRacer::Context.new + context.attach("rubyAdd", proc { |a, b| a + b }) + context.eval(<<~JS) + async function viaRuby() { + await Promise.resolve(); + return rubyAdd(20, 22); + } + JS + assert_equal 42, context.call_await("viaRuby") + end + + def test_call_await_ruby_callback_exception_propagates + context = MiniRacer::Context.new + context.attach("rubyBoom", proc { raise "ruby boom" }) + context.eval(<<~JS) + async function boomRuby() { + await Promise.resolve(); + return rubyBoom(); + } + JS + err = assert_raises(RuntimeError) { context.call_await("boomRuby") } + assert_includes err.message, "ruby boom" + end + + def test_nested_call_await_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach("rubyCallsAsync", proc { context.call_await("inner") }) + context.eval(<<~JS) + async function inner() { + await Promise.resolve(); + return 42; + } + + async function outer() { + await Promise.resolve(); + return rubyCallsAsync(); + } + JS + + err = + assert_raises(MiniRacer::RuntimeError) do + Timeout.timeout(2) { context.call_await("outer") } + end + assert_includes err.message, "nested call_await/eval_await" + assert_equal 2, context.eval("1 + 1") + end + + def test_nested_eval_await_fails_instead_of_deadlocking + context = MiniRacer::Context.new + context.attach( + "rubyEvalsAsync", + proc do + context.eval_await( + "(async () => { await Promise.resolve(); return 42 })()" + ) + end + ) + context.eval(<<~JS) + async function outer() { + await Promise.resolve(); + return rubyEvalsAsync(); + } + JS + + err = + assert_raises(MiniRacer::RuntimeError) do + Timeout.timeout(2) { context.call_await("outer") } + end + assert_includes err.message, "nested call_await/eval_await" + assert_equal 2, context.eval("1 + 1") + end + + def test_eval_await_delayed_task + context = MiniRacer::Context.new + result = context.eval_await(<<~JS) + (async () => { + const i32 = new Int32Array(new SharedArrayBuffer(4)); + return (await Atomics.waitAsync(i32, 0, 0, 20).value); + })() + JS + assert_equal "timed-out", result + end + + def test_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 + ) + + source = <<~JS + runSlowEval(); + (() => { + const start = Date.now(); + while (Date.now() - start < 500) {} + })(); + JS + + assert_raises(MiniRacer::ScriptTerminatedError) do + Timeout.timeout(2) { context.eval(source) } + 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") }) + 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) + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval_await("new Promise(() => {})") + end + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + assert_operator elapsed, :<, 5 + assert_equal 2, context.eval("1 + 1") + end + + def test_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( + "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_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 + 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_equal value_size, context.eval("value").bytesize + assert_equal 2, context.eval("1 + 1") + end + + def test_never_settling_promise_interrupted_by_stop + context = MiniRacer::Context.new + stopper = + Thread.new do + sleep 0.1 + context.stop + end + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval_await("new Promise(() => {})") + end + stopper.join + assert_equal 2, context.eval("1 + 1") + end + + def test_never_settling_promise_interrupted_by_thread_kill + context = MiniRacer::Context.new + thread = + Thread.new do + context.eval_await("new Promise(() => {})") + rescue MiniRacer::ScriptTerminatedError + nil + end + sleep 0.1 + thread.kill + assert thread.join(3), "awaiting thread did not stop" + assert_equal 2, context.eval("1 + 1") + end + + def test_call_sync_does_not_await + context = MiniRacer::Context.new + context.eval("async function f() { return 42 }") + assert_equal({}, context.call("f")) + end + + def test_dispose_after_call_await + context = MiniRacer::Context.new + context.eval("async function f() { return 1 }") + assert_equal 1, context.call_await("f") + context.dispose + assert_raises(MiniRacer::ContextDisposedError) { context.call_await("f") } + end +end diff --git a/test/function_test.rb b/test/function_test.rb index 01e90f0..1dbc51c 100644 --- a/test/function_test.rb +++ b/test/function_test.rb @@ -47,6 +47,19 @@ def test_args_types assert_equal "I need 1,2,3 bars", res end + def test_arguments_passed_exactly + context = MiniRacer::Context.new + context.eval("function count() { return arguments.length }") + assert_equal 2, context.call("count", 1, 2) + assert_equal 0, context.call("count") + end + + def test_trailing_hash_is_positional_argument + context = MiniRacer::Context.new + context.eval("function echo(h) { return h }") + assert_equal({ "key" => "value" }, context.call("echo", key: "value")) + end + def test_complex_return context = MiniRacer::Context.new context.eval("function f(x, y) { return { vx: x, vy: y, array: [x, y] } }") diff --git a/test/single_threaded_test.rb b/test/single_threaded_test.rb index 9340f83..60ac7aa 100644 --- a/test/single_threaded_test.rb +++ b/test/single_threaded_test.rb @@ -51,6 +51,23 @@ def test_ruby_callback_from_javascript RUBY end + def test_call_await_and_eval_await + assert_single_threaded_script <<~'RUBY' + context = MiniRacer::Context.new + context.eval("async function f(x) { await Promise.resolve(); return x * 2 }") + raise "bad async call" unless context.call_await("f", 21) == 42 + raise "bad async eval" unless context.eval_await("(async () => 6 * 7)()") == 42 + + context = MiniRacer::Context.new(timeout: 200) + begin + context.eval_await("new Promise(() => {})") + raise "expected termination" + rescue MiniRacer::ScriptTerminatedError + end + raise "context unusable after termination" unless context.eval("1 + 1") == 2 + RUBY + end + def test_nested_javascript_ruby_javascript_call assert_single_threaded_script <<~'RUBY' context = MiniRacer::Context.new