Skip to content
Closed
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,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 `<namespace>.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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 33 additions & 2 deletions ext/mini_racer_extension/mini_racer_extension.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1650,14 +1654,41 @@ 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 `<name>.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);
}
}
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)))
Expand Down
59 changes: 58 additions & 1 deletion ext/mini_racer_extension/mini_racer_v8.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <namespace>.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<v8::Value>& info)
{
auto ext = v8::External::Cast(*info.Data());
State& st = *static_cast<State*>(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;
Expand Down Expand Up @@ -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<v8::Function>::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<v8::String> 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);
Expand Down
3 changes: 2 additions & 1 deletion ext/mini_racer_extension/mini_racer_v8.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions lib/mini_racer/shared.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
Loading
Loading