diff --git a/CHANGELOG b/CHANGELOG index 1fb18a7..4fa3dbc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ - (Unreleased) - Add `Context#perform_microtask_checkpoint` to synchronously drain the V8 microtask queue, useful for spec-compliant `dispatchEvent` sequencing inside Ruby callbacks + - Add an opt-in JavaScript host namespace via `Context.new(host_namespace:)` (à la Deno's `Deno`/Bun's `Bun`); when enabled it exposes `.drainMicrotasks()`, an inline (no Ruby round-trip) microtask checkpoint for draining between synchronous JS operations - 0.21.1 - 25-05-2026 - Run `:single_threaded` V8 dispatches on a reusable mini_racer-owned native thread so V8 does not execute on Ruby-owned threads diff --git a/README.md b/README.md index 6c0b0b0..1534fb9 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,25 @@ context.eval("log") Without `drain()` the order would be `["before", "after", "microtask"]` because the microtask only runs once the outermost script returns. `perform_microtask_checkpoint` is a thin wrapper over V8's `MicrotasksScope::PerformCheckpoint`. +When the drain has to happen from within JavaScript itself — for example between each listener in a synchronous `dispatchEvent` chain — the same checkpoint is available to JS as `drainMicrotasks()`. It runs inline on the V8 thread without the Ruby ↔ V8 round-trip, so no `attach` is required. + +It is exposed through an opt-in **host namespace** — a single object (in the spirit of Deno's `Deno` or Bun's `Bun`) that mini_racer hangs its non-standard helpers off. Pass `host_namespace:` to enable it; by default nothing is injected and the global stays clean: + +```ruby +context = MiniRacer::Context.new(host_namespace: "MiniRacer") +context.eval(<<~JS) + globalThis.log = []; + Promise.resolve().then(() => log.push("microtask")); + log.push("before"); + MiniRacer.drainMicrotasks(); + log.push("after"); +JS +context.eval("log") +# => ["before", "microtask", "after"] +``` + +`host_namespace:` accepts a String (the global name to use — it must be a valid JavaScript identifier), `true` (the default name `"MiniRacer"`), or `nil`/`false` (the default — inject nothing). The namespace object is defined non-enumerable so it does not appear in `Object.keys(globalThis)`, while its methods are ordinary properties discoverable via `Object.keys(MiniRacer)`. Like `perform_microtask_checkpoint`, `drainMicrotasks()` is a no-op while a microtask checkpoint is already in progress, and it lets watchdog/out-of-memory termination propagate to the enclosing `eval`/`call`. (The host namespace is V8-only; it is not installed on the TruffleRuby backend.) + ## Performance The `bench` folder contains benchmark. diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 496d011..55f46ec 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -137,6 +137,7 @@ typedef struct Context VALUE exception; // pending exception or Qnil Buf req, res; // ruby->v8 request/response, mediated by |mtx| and |cv| Buf snapshot; + Buf host_namespace; // NUL-terminated global name to install host helpers on, or empty pthread_t single_threaded_thr; pid_t single_threaded_pid; int single_threaded_thr_started; @@ -901,7 +902,8 @@ static void *v8_thread_start(void *arg) c = arg; barrier_wait(&c->early_init); v8_once_init(); - v8_thread_init(c, c->snapshot.buf, c->snapshot.len, c->max_memory, c->verbose_exceptions); + v8_thread_init(c, c->snapshot.buf, c->snapshot.len, c->max_memory, c->verbose_exceptions, + c->host_namespace.len ? (const char *)c->host_namespace.buf : NULL); while (c->quit < 2) pthread_cond_wait(&c->cv, &c->mtx); context_destroy(c); @@ -1175,6 +1177,7 @@ static VALUE context_alloc(VALUE klass) c->exception = Qnil; c->procs = rb_ary_new(); buf_init(&c->snapshot); + buf_init(&c->host_namespace); buf_init(&c->req); buf_init(&c->res); cause = "pthread_condattr_init"; @@ -1293,6 +1296,7 @@ static void context_destroy(Context *c) pthread_mutex_destroy(&c->wd.mtx); pthread_cond_destroy(&c->wd.cv); buf_reset(&c->snapshot); + buf_reset(&c->host_namespace); buf_reset(&c->req); buf_reset(&c->res); ruby_xfree(c); @@ -1650,6 +1654,32 @@ static VALUE context_initialize(int argc, VALUE *argv, VALUE self) rb_raise(runtime_error, "out of memory"); } else if (!strcmp(s, "verbose_exceptions")) { c->verbose_exceptions = !(v == Qfalse || v == Qnil); + } else if (!strcmp(s, "host_namespace")) { + const char *ns = NULL; + if (v == Qtrue) { + ns = "MiniRacer"; // default brand, like Deno's `Deno` + } else if (v != Qnil && v != Qfalse) { + Check_Type(v, T_STRING); + ns = StringValueCStr(v); // raises on embedded NUL + } + if (ns && *ns) { + // The name becomes a global, so require a valid (ASCII) JS + // identifier; otherwise it would only be reachable through + // globalThis["..."] rather than as `.method()`. + for (const char *q = ns; *q; q++) { + int ch = (unsigned char)*q; + int ident_start = ch == '_' || ch == '$' || + (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); + int ident_char = ident_start || (ch >= '0' && ch <= '9'); + if (!(q == ns ? ident_start : ident_char)) + rb_raise(rb_eArgError, + "host_namespace must be a valid identifier: %s", ns); + } + // store the name plus its NUL terminator + buf_reset(&c->host_namespace); + if (buf_put(&c->host_namespace, ns, strlen(ns) + 1)) + rb_raise(runtime_error, "out of memory"); + } } else { rb_raise(runtime_error, "bad keyword: %s", s); } @@ -1657,7 +1687,8 @@ static VALUE context_initialize(int argc, VALUE *argv, VALUE self) init: if (single_threaded) { v8_once_init(); - c->pst = v8_thread_init(c, c->snapshot.buf, c->snapshot.len, c->max_memory, c->verbose_exceptions); + c->pst = v8_thread_init(c, c->snapshot.buf, c->snapshot.len, c->max_memory, c->verbose_exceptions, + c->host_namespace.len ? (const char *)c->host_namespace.buf : NULL); } else { cause = "pthread_attr_init"; if ((r = pthread_attr_init(&attr))) diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 591d5e2..5d3b269 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -314,9 +314,41 @@ void v8_gc_callback(v8::Isolate*, v8::GCType, v8::GCCallbackFlags, void *data) } } +// Native, rendezvous-free microtask checkpoint. When the embedder opts in via +// Context.new(host_namespace:), it is hung off the host namespace as +// .drainMicrotasks(). Unlike Context#perform_microtask_checkpoint +// (dispatch tag 'M') this runs inline on the isolate thread and never +// round-trips through the Ruby<->V8 rendezvous, so JS can drain the queue +// mid-execution -- e.g. between synchronous dispatchEvent listeners -- for +// ~sub-microsecond cost. It mirrors v8_perform_microtask_checkpoint but +// without the reply, and deliberately leaves any termination active so the +// enclosing v8_call/v8_eval frame surfaces OOM (v8_gc_callback) or watchdog +// termination to Ruby. +void v8_drain_microtasks_callback(const v8::FunctionCallbackInfo& info) +{ + auto ext = v8::External::Cast(*info.Data()); + State& st = *static_cast(ext->Value()); + // Do *not* take a v8::Locker here: in single-threaded mode V8 already holds + // the isolate on this (the Ruby) thread, so locking would deadlock. + // + // An uncaught exception thrown by a drained microtask is routed by V8 to + // its message/unhandled-rejection handlers, not propagated out of + // PerformCheckpoint, so this TryCatch normally catches nothing; it exists + // only to mirror v8_perform_microtask_checkpoint and honor verbose_exceptions. + // It must not (and does not) clear a pending termination. + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + // PerformCheckpoint is a guarded no-op when the microtask depth is > 0, so + // it is safe to call mid-execution and never force-nests microtask runs. + v8::MicrotasksScope::PerformCheckpoint(st.isolate); + info.GetReturnValue().SetUndefined(); +} + extern "C" State *v8_thread_init(Context *c, const uint8_t *snapshot_buf, size_t snapshot_len, int64_t max_memory, - int verbose_exceptions) + int verbose_exceptions, + const char *host_namespace) { State *pst = new State{}; State& st = *pst; @@ -363,6 +395,31 @@ extern "C" State *v8_thread_init(Context *c, const uint8_t *snapshot_buf, st.safe_context->UseDefaultSecurityToken(); st.safe_context_function = v8::Local::Cast(function_v); } + // If the embedder opted in via Context.new(host_namespace:), install a + // single host-namespace object (in the spirit of Deno's `Deno` / Bun's + // `Bun`) under that global name and hang native helpers off it. The + // object closes over native code pointers so it cannot live in the + // (de)serialized snapshot; it is installed here on every fresh context. + // Both multi-threaded and single-threaded contexts (and snapshot-backed + // ones) reach this point exactly once via v8_thread_init, so this + // covers them all. The namespace is non-enumerable on globalThis so it + // stays out of Object.keys(globalThis)/for-in; its methods are ordinary + // enumerable properties so they remain discoverable on the object. + if (host_namespace && *host_namespace) { + v8::Local ns_name; + if (v8::String::NewFromUtf8(st.isolate, host_namespace).ToLocal(&ns_name)) { + auto ns = v8::Object::New(st.isolate); + auto data = v8::External::New(st.isolate, pst); + auto drain_name = v8::String::NewFromUtf8Literal(st.isolate, "drainMicrotasks"); + auto drain = + v8::Function::New(st.context, v8_drain_microtasks_callback, data) + .ToLocalChecked(); + ns->Set(st.context, drain_name, drain).Check(); + st.context->Global() + ->DefineOwnProperty(st.context, ns_name, ns, v8::DontEnum) + .Check(); + } + } if (single_threaded) { st.persistent_safe_context_function.Reset(st.isolate, st.safe_context_function); st.persistent_safe_context.Reset(st.isolate, st.safe_context); diff --git a/ext/mini_racer_extension/mini_racer_v8.h b/ext/mini_racer_extension/mini_racer_v8.h index 57f12fb..9b8f615 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -36,7 +36,8 @@ void v8_roundtrip(struct Context *c, const uint8_t **p, size_t *n); void v8_global_init(void); struct State *v8_thread_init(struct Context *c, const uint8_t *snapshot_buf, size_t snapshot_len, int64_t max_memory, - int verbose_exceptions); // calls v8_thread_main + int verbose_exceptions, + const char *host_namespace); // 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_eval(struct State *pst, const uint8_t *p, size_t n); diff --git a/lib/mini_racer/shared.rb b/lib/mini_racer/shared.rb index 657f1ee..006f749 100644 --- a/lib/mini_racer/shared.rb +++ b/lib/mini_racer/shared.rb @@ -99,10 +99,10 @@ def initialize(name, callback, parent) end end - def initialize(max_memory: nil, timeout: nil, isolate: nil, ensure_gc_after_idle: nil, snapshot: nil, marshal_stack_depth: nil) + def initialize(max_memory: nil, timeout: nil, isolate: nil, ensure_gc_after_idle: nil, snapshot: nil, marshal_stack_depth: nil, host_namespace: nil) options ||= {} - check_init_options!(isolate: isolate, snapshot: snapshot, max_memory: max_memory, marshal_stack_depth: marshal_stack_depth, ensure_gc_after_idle: ensure_gc_after_idle, timeout: timeout) + check_init_options!(isolate: isolate, snapshot: snapshot, max_memory: max_memory, marshal_stack_depth: marshal_stack_depth, ensure_gc_after_idle: ensure_gc_after_idle, timeout: timeout, host_namespace: host_namespace) @functions = {} @timeout = nil @@ -310,7 +310,7 @@ def timeout(&blk) rp&.close end - def check_init_options!(isolate:, snapshot:, max_memory:, marshal_stack_depth:, ensure_gc_after_idle:, timeout:) + def check_init_options!(isolate:, snapshot:, max_memory:, marshal_stack_depth:, ensure_gc_after_idle:, timeout:, host_namespace:) assert_option_is_nil_or_a('isolate', isolate, Isolate) assert_option_is_nil_or_a('snapshot', snapshot, Snapshot) @@ -319,6 +319,14 @@ def check_init_options!(isolate:, snapshot:, max_memory:, marshal_stack_depth:, assert_numeric_or_nil('ensure_gc_after_idle', ensure_gc_after_idle, min_value: 1) assert_numeric_or_nil('timeout', timeout, min_value: 1) + unless host_namespace.nil? || host_namespace == true || host_namespace == false || host_namespace.is_a?(String) + raise ArgumentError, "host_namespace must be a String, true, false, or nil, passed a #{host_namespace.inspect}" + end + + if host_namespace.is_a?(String) && !host_namespace.empty? && !host_namespace.match?(/\A[A-Za-z_$][A-Za-z0-9_$]*\z/) + raise ArgumentError, "host_namespace must be a valid identifier, passed #{host_namespace.inspect}" + end + if isolate && snapshot raise ArgumentError, 'can only pass one of isolate and snapshot options' end diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index a9ffe38..6cd0951 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1008,6 +1008,189 @@ def test_perform_microtask_checkpoint_drains_from_callback assert_equal(%w[before-drain microtask-fired after-drain], seen) end + # Creates a context with the host namespace installed, skipping on backends + # that do not implement it (V8/CRuby only, like perform_microtask_checkpoint). + def host_namespace_context(host_namespace: "MiniRacer", **options) + if RUBY_ENGINE == "truffleruby" + skip "host_namespace is only implemented on the V8 backend" + end + MiniRacer::Context.new(host_namespace: host_namespace, **options) + end + + def test_host_namespace_not_installed_by_default + context = MiniRacer::Context.new + + # Opt-in only: without host_namespace nothing is injected. (drainMicrotasks + # is the real method name; assert it never leaks as a bare global either.) + assert_equal("undefined", context.eval("typeof MiniRacer")) + assert_equal("undefined", context.eval("typeof drainMicrotasks")) + end + + def test_host_namespace_false_or_empty_is_off + assert_equal("undefined", MiniRacer::Context.new(host_namespace: false).eval("typeof MiniRacer")) + assert_equal("undefined", MiniRacer::Context.new(host_namespace: "").eval("typeof MiniRacer")) + end + + def test_host_namespace_rejects_invalid_type + # CRuby (C Check_Type) raises TypeError; TruffleRuby (shared.rb) raises + # ArgumentError. Accept either so the test is backend-portable. + assert_raises(TypeError, ArgumentError) do + MiniRacer::Context.new(host_namespace: 123) + end + end + + def test_host_namespace_rejects_invalid_identifier + # Non-identifier names would only be reachable via globalThis["..."], not as + # `.drainMicrotasks()`, so they are rejected up front. + ["foo-bar", "foo.bar", "123abc", "with space", "a/b"].each do |name| + assert_raises(ArgumentError) do + MiniRacer::Context.new(host_namespace: name) + end + end + end + + def test_host_namespace_allows_identifier_punctuation + context = host_namespace_context(host_namespace: "$mr_2") + assert_equal("function", context.eval("typeof $mr_2.drainMicrotasks")) + end + + def test_host_namespace_true_uses_default_name + context = host_namespace_context(host_namespace: true) + assert_equal("function", context.eval("typeof MiniRacer.drainMicrotasks")) + end + + def test_host_namespace_accepts_a_custom_name + context = host_namespace_context(host_namespace: "App") + + assert_equal("function", context.eval("typeof App.drainMicrotasks")) + assert_equal("undefined", context.eval("typeof MiniRacer")) + end + + def test_host_namespace_is_non_enumerable_but_methods_are_discoverable + context = host_namespace_context + + # The namespace object itself stays out of globalThis enumeration... + refute(context.eval("Object.keys(globalThis).includes('MiniRacer')")) + refute(context.eval("Object.getOwnPropertyDescriptor(globalThis, 'MiniRacer').enumerable")) + # ...but its methods are ordinary own properties, so they are discoverable. + assert_equal(%w[drainMicrotasks], context.eval("Object.keys(MiniRacer)")) + end + + def test_host_namespace_drain_microtasks_inline + context = host_namespace_context + + # Unlike perform_microtask_checkpoint, no Ruby callback round-trip is + # needed: JS drains the queue mid-execution by calling the native method. + order = context.eval(<<~JS) + const seen = []; + Promise.resolve().then(() => seen.push("microtask-fired")); + seen.push("before-drain"); + MiniRacer.drainMicrotasks(); + seen.push("after-drain"); + seen; + JS + + assert_equal(%w[before-drain microtask-fired after-drain], order) + end + + def test_host_namespace_drain_microtasks_is_a_noop_when_nested + context = host_namespace_context + + # Calling it from inside a running microtask (depth > 0) must be a guarded + # no-op that does not synchronously re-enter the queue. m1 enqueues m3 then + # drains; under a correct no-op m3 runs only after m1 returns, so "m1-end" + # precedes "m3". A re-entering (force-nesting) implementation would run m3 + # inside m1 and yield "m3" before "m1-end". + order = context.eval(<<~JS) + const seen = []; + Promise.resolve().then(() => { + seen.push("m1-start"); + Promise.resolve().then(() => seen.push("m3")); + MiniRacer.drainMicrotasks(); + seen.push("m1-end"); + }); + MiniRacer.drainMicrotasks(); + seen; + JS + + assert_equal(%w[m1-start m1-end m3], order) + end + + def test_host_namespace_drain_microtasks_does_not_propagate_microtask_exceptions + context = host_namespace_context + + # An exception thrown by a drained microtask is routed to V8's handlers + # (as with perform_microtask_checkpoint), not propagated to the caller: the + # drain returns normally and the context stays usable. + order = context.eval(<<~JS) + const seen = []; + Promise.resolve().then(() => { throw new Error("boom"); }); + seen.push("before"); + MiniRacer.drainMicrotasks(); + seen.push("after"); + seen; + JS + + assert_equal(%w[before after], order) + assert_equal(2, context.eval("1 + 1")) + end + + def test_host_namespace_drain_microtasks_returns_undefined + context = host_namespace_context + assert_nil(context.eval("MiniRacer.drainMicrotasks()")) + end + + def test_host_namespace_installed_on_snapshot_backed_context + snapshot = MiniRacer::Snapshot.new("var fromSnapshot = 42;") + context = host_namespace_context(snapshot: snapshot) + + # The namespace closes over native pointers and is not part of the + # snapshot; it must be (re)installed on every fresh context. + assert_equal("function", context.eval("typeof MiniRacer.drainMicrotasks")) + assert_equal(42, context.eval("fromSnapshot")) + end + + def test_host_namespace_drain_microtasks_surfaces_termination + context = host_namespace_context(timeout: 50) + + # A runaway microtask drained here must let watchdog termination propagate + # to the enclosing eval. Asserting that the trailing statement did not run + # (rather than only that an error was raised) proves the termination came + # from the inline drain: were drainMicrotasks() a no-op, the kAuto + # checkpoint at script end would still terminate, but reached_after would + # already be true. + assert_raises(MiniRacer::ScriptTerminatedError) do + context.eval(<<~JS) + globalThis.reached_after = false; + Promise.resolve().then(() => { while (true) {} }); + MiniRacer.drainMicrotasks(); + globalThis.reached_after = true; + JS + end + + refute(context.eval("globalThis.reached_after")) + end + + def test_host_namespace_drain_microtasks_surfaces_out_of_memory + context = host_namespace_context(max_memory: 100_000_000) + + # Same as above for the out-of-memory path (v8_gc_callback), which the + # README documents alongside watchdog termination. + assert_raises(MiniRacer::V8OutOfMemoryError) do + context.eval(<<~JS) + globalThis.reached_after = false; + Promise.resolve().then(() => { + let s = 1000, a = new Array(s); a.fill(0); + while (true) { s *= 1.1; let n = new Array(Math.floor(s)); n.fill(0); a = a.concat(n); } + }); + MiniRacer.drainMicrotasks(); + globalThis.reached_after = true; + JS + end + + refute(context.eval("globalThis.reached_after")) + end + def test_webassembly if RUBY_ENGINE == "truffleruby" skip "TruffleRuby does not enable WebAssembly by default" diff --git a/test/single_threaded_test.rb b/test/single_threaded_test.rb index 3d4bb45..24b6ec0 100644 --- a/test/single_threaded_test.rb +++ b/test/single_threaded_test.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "open3" require "rbconfig" require "tempfile" +require "timeout" class MiniRacerSingleThreadedTest < Minitest::Test - def assert_single_threaded_script(script) + def assert_single_threaded_script(script, timeout: 60) skip "single-threaded V8 platform tests are only for CRuby" unless RUBY_ENGINE == "ruby" file = Tempfile.new(["mini_racer_single_threaded", ".rb"]) @@ -20,13 +20,28 @@ def assert_single_threaded_script(script) RUBY file.close - stdout, stderr, status = Open3.capture3(RbConfig.ruby, file.path) + # Run with a bounded wait and kill on timeout so a regression that + # deadlocks the single-threaded runner (e.g. a v8::Locker taken on the + # shared Ruby thread) fails deterministically instead of hanging the suite. + read, write = IO.pipe + pid = Process.spawn(RbConfig.ruby, file.path, out: write, err: write) + write.close + reader = Thread.new { read.read } + + begin + _, status = Timeout.timeout(timeout) { Process.wait2(pid) } + rescue Timeout::Error + Process.kill("KILL", pid) + Process.wait(pid) + flunk "single-threaded script did not finish within #{timeout}s (possible deadlock):\n#{reader.value}" + end + + output = reader.value + read.close assert status.success?, <<~MSG single-threaded script failed with status #{status.exitstatus} - stdout: - #{stdout} - stderr: - #{stderr} + output: + #{output} MSG ensure file&.unlink @@ -136,4 +151,21 @@ def test_fork_after_runner_started_and_idle raise "child failed" unless $?.success? RUBY end + + def test_host_namespace_drain_microtasks + # The native checkpoint runs inline on the isolate thread and must not take + # a v8::Locker, which would deadlock when V8 shares the Ruby thread. + assert_single_threaded_script <<~'RUBY' + context = MiniRacer::Context.new(host_namespace: "MiniRacer") + order = context.eval(<<~JS) + const seen = []; + Promise.resolve().then(() => seen.push("microtask-fired")); + seen.push("before-drain"); + MiniRacer.drainMicrotasks(); + seen.push("after-drain"); + seen; + JS + raise "bad drain order: #{order.inspect}" unless order == %w[before-drain microtask-fired after-drain] + RUBY + end end