diff --git a/CHANGELOG b/CHANGELOG index 1fb18a7..d3b1f50 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 ES module API via `Context#compile_module` / `MiniRacer::Module` (#421): `compile_module(source, filename:)` returns a Module handle bound to the Context (and exposes `filename` to the module as `import.meta.url`); `Module#instantiate { |specifier, referrer_url| ... }` walks static imports via a Ruby-side resolver block (the referrer URL is the importing module's filename, so relative specifiers can be resolved); `Module#evaluate` runs the module body; `Module#namespace` returns the Module Namespace Object as a Hash; `Module#status` exposes V8's lifecycle state; `Module#dispose` / `Module#disposed?` mirror `Script`'s eager handle release. `Context#dynamic_import_resolver=` handles JS `import(...)` expressions (resolver receives specifier + referrer URL, returns a `MiniRacer::Module`; the loaded module is evaluated if pending). Top-level await is not supported in this round (evaluate raises if the evaluation promise stays pending). The TruffleRuby shim raises `NotImplementedError`. - 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..b5ff53d 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,79 @@ 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`. +### ES modules + +`Context#compile_module` exposes V8's ES module API for code that uses +`import` / `export` syntax. Unlike `eval` (which only accepts script-level +syntax), modules can have static imports that resolve to other modules and +expose named exports through a real Module Namespace Object. + +```ruby +context = MiniRacer::Context.new + +dep = context.compile_module("export const base = 10", filename: "dep.js") +main = context.compile_module(<<~JS, filename: "main.js") + import { base } from 'dep' + export const doubled = base * 2 +JS + +main.instantiate {|specifier, referrer| dep } # called once per static import +dep.evaluate +main.evaluate + +main.namespace # => {"doubled" => 20} +``` + +* `Context#compile_module(source, filename:)` — parses the source as a + module; the returned `MiniRacer::Module` is bound to its Context. The + `filename` is also exposed to the module as `import.meta.url`. +* `Module#instantiate { |specifier, referrer_url| ... }` — walks the static + import graph. The resolver block is called once per import declaration + with the raw specifier string and the importing module's filename, so + relative specifiers (`./foo`, `../bar`) can be resolved against the + referrer. It must return another `MiniRacer::Module` (typically from a + per-Context cache). Imports can also be resolved lazily from inside the + block via further `Context#compile_module` calls. +* `Module#evaluate` — runs the module body. Returns the evaluation result + (`nil` for the typical `export const …` shape). Modules with top-level + `await` raise `MiniRacer::RuntimeError` for now. +* `Module#namespace` — returns the Module Namespace Object as a Hash + (`{ "default" => …, "namedExport" => … }`). Only available after + `evaluate` succeeds; calling it earlier raises `MiniRacer::RuntimeError` + (the export bindings are not yet initialized), and on an errored module it + re-raises the module's own error. +* `Module#status` — one of `:uninstantiated`, `:instantiating`, + `:instantiated`, `:evaluating`, `:evaluated`, `:errored`. +* `Module#dispose` / `Module#disposed?` — eager handle release, mirroring + the convention used elsewhere. +* `Context#dynamic_import_resolver = proc { |specifier, referrer_url| ... }` + — handler for JS `import(...)` expressions. The proc must return a + `MiniRacer::Module` (already instantiated; `evaluate` is driven for you + if pending). Set to `nil` to reject all dynamic imports. Drain the + microtask queue with `Context#perform_microtask_checkpoint` to see the + result in a `.then` callback or after `await`. + +```ruby +context.dynamic_import_resolver = ->(spec, _ref) { cache.fetch(spec) } +context.eval(%(import('dep').then(ns => globalThis.r = ns.x)), filename: 'caller.js') +context.perform_microtask_checkpoint +context.eval('globalThis.r') # => 42 +``` + +Notes: + +- A `Module` is bound to the `Context` that compiled it; resolvers must + return modules from the same Context. +- `Module#dispose` frees the underlying V8 handle eagerly. The Ruby GC + finalizer does not (taking the V8 lock from a finalizer thread risks + deadlock), so long-lived Contexts with many short-lived modules + accumulate handles until `Context#dispose` clears them. +- Top-level await is not yet supported; `evaluate` raises if the + module's evaluation promise stays pending after the microtask drain. +- On TruffleRuby, `Context#compile_module` raises `NotImplementedError` + — GraalJS has its own module-loading mechanism that doesn't map onto + this handle-based API. PRs to bridge are welcome. + ## 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..c25c163 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -135,6 +135,17 @@ typedef struct Context struct State *pst; // used by v8 thread VALUE procs; // array of js -> ruby callbacks VALUE exception; // pending exception or Qnil + // Per-instantiate resolver Proc, Qnil when none active. + // module_instantiate saves/restores via rb_ensure to keep the slot + // consistent across exceptions and nested compile_module + instantiate + // from inside the resolver block. Concurrent Module#instantiate calls + // on the same Context from different Ruby threads race on this slot; + // callers must serialize externally — the rest of mini_racer's Context + // API has the same single-threaded-per-Context expectation. + VALUE resolve_block; + // Callable for `import(...)` in JS, or Qnil to reject dynamic imports + // with a clear error. Set via Context#dynamic_import_resolver=. + VALUE dynamic_import_resolver; Buf req, res; // ruby->v8 request/response, mediated by |mtx| and |cv| Buf snapshot; pthread_t single_threaded_thr; @@ -158,6 +169,18 @@ typedef struct Snapshot { VALUE blob; } Snapshot; +// GC-finalizer caveat: module_free cannot send a dispose RPC (would need +// to take rr_mtx without a reliable GVL guarantee). Handles freed here +// rely on State::~State() walking st.modules at isolate teardown — so +// long-lived Contexts with many short-lived Modules accumulate Persistents +// until the Context is disposed. Call Module#dispose explicitly to free +// eagerly. +typedef struct Module { + VALUE context; // parent Context VALUE (kept alive via mark) + int32_t handle_id; // 0 if uninitialized or already freed + int disposed; +} Module; + static void context_destroy(Context *c); static void context_free(void *arg); static void context_mark(void *arg); @@ -185,6 +208,19 @@ static const rb_data_type_t snapshot_type = { }, }; +static void module_free(void *arg); +static void module_mark(void *arg); +static size_t module_size(const void *arg); + +static const rb_data_type_t module_type = { + .wrap_struct_name = "mini_racer/module", + .function = { + .dfree = module_free, + .dmark = module_mark, + .dsize = module_size, + }, +}; + static VALUE platform_init_error; static VALUE context_disposed_error; static VALUE parse_error; @@ -196,10 +232,13 @@ static VALUE snapshot_error; static VALUE terminated_error; static VALUE context_class; static VALUE snapshot_class; +static VALUE module_class; static VALUE date_time_class; static VALUE binary_class; static VALUE js_function_class; +static ID id_filename; + static pthread_mutex_t flags_mtx = PTHREAD_MUTEX_INITIALIZER; static Buf flags; // protected by |flags_mtx| @@ -810,11 +849,17 @@ static void dispatch1(Context *c, const uint8_t *p, size_t n) case 'C': return v8_timedwait(c, p+1, n-1, v8_call); case 'E': return v8_timedwait(c, p+1, n-1, v8_eval); case 'H': return v8_heap_snapshot(c->pst); + case 'I': return v8_timedwait(c, p+1, n-1, v8_instantiate_module); // (I)nstantiate module case 'M': return v8_perform_microtask_checkpoint(c->pst); + case 'N': return v8_module_namespace(c->pst, p+1, n-1); // (N)amespace + case 'O': return v8_timedwait(c, p+1, n-1, v8_compile_module); // (O)bject-module compile case 'P': return v8_pump_message_loop(c->pst); case 'S': return v8_heap_stats(c->pst); case 'T': return v8_snapshot(c->pst, p+1, n-1); + case 'U': return v8_module_status(c->pst, p+1, n-1); // (U) module status — non-blocking + case 'V': return v8_timedwait(c, p+1, n-1, v8_evaluate_module); // e(V)aluate case 'W': return v8_warmup(c->pst, p+1, n-1); + case 'Z': return v8_dispose_module(c->pst, p+1, n-1); // (Z) dispose module handle case 'L': b = 0; v8_reply(c, &b, 1); // doesn't matter what as long as it's not empty @@ -988,6 +1033,165 @@ static void *rendezvous_callback(void *arg) goto out; } +// called with |rr_mtx| and GVL held; can raise exception +static VALUE rendezvous_resolve_do(VALUE arg) +{ + struct rendezvous_nogvl *a; + VALUE args, specifier, referrer_url, ret; + Context *c; + Module *m; + DesCtx d; + Buf *b; + + a = (void *)arg; + b = a->res; + c = a->context; + assert(b->len > 0); + assert(*b->buf == 'm'); + DesCtx_init(&d); + args = deserialize1(&d, b->buf+1, b->len-1); // skip 'm' marker + // args: [specifier, referrer_url] + specifier = rb_ary_entry(args, 0); + referrer_url = rb_ary_entry(args, 1); + if (NIL_P(c->resolve_block)) + rb_raise(runtime_error, "module resolver requested but no resolver block is active"); + ret = rb_funcall(c->resolve_block, rb_intern("call"), 2, specifier, referrer_url); + if (!rb_obj_is_kind_of(ret, module_class)) + rb_raise(runtime_error, "module resolver must return a MiniRacer::Module, got %s", + rb_obj_classname(ret)); + TypedData_Get_Struct(ret, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "module resolver returned a disposed Module"); + // Reject cross-Context modules explicitly: handle ids restart at 1 + // per Context, so without this check a foreign Module silently maps + // to whichever local module happens to share its id. + { + Context *mc; + TypedData_Get_Struct(m->context, Context, &context_type, mc); + if (mc != c) + rb_raise(runtime_error, + "module resolver returned a Module from a different Context"); + } + return INT2FIX(m->handle_id); +} + +// called with |rr_mtx| and GVL held; |mtx| is unlocked +// resolver data is in |a->res|, serialized handle id goes in |a->req| +static void *rendezvous_resolve(void *arg) +{ + struct rendezvous_nogvl *a; + const char *err; + Context *c; + int exc; + VALUE r; + Ser s; + + a = arg; + c = a->context; + r = rb_protect(rendezvous_resolve_do, (VALUE)a, &exc); + if (exc) { + c->exception = rb_errinfo(); + rb_set_errinfo(Qnil); + goto fail; + } + ser_init1(&s, 'm'); // resolve reply (matches request marker) + if (serialize(&s, r)) { // should not happen + c->exception = rb_exc_new_cstr(internal_error, s.err); + ser_reset(&s); + goto fail; + } +out: + buf_move(&s.b, a->req); + return NULL; +fail: + ser_init0(&s); + w_byte(&s, 'e'); + r = rb_funcall(c->exception, rb_intern("to_s"), 0); + err = StringValueCStr(r); + if (err) + w(&s, err, strlen(err)); + goto out; +} + +// called with |rr_mtx| and GVL held; can raise exception +static VALUE rendezvous_dynamic_import_do(VALUE arg) +{ + struct rendezvous_nogvl *a; + VALUE args, specifier, referrer_url, ret; + Context *c; + Module *m; + DesCtx d; + Buf *b; + + a = (void *)arg; + b = a->res; + c = a->context; + assert(b->len > 0); + assert(*b->buf == 'd'); + DesCtx_init(&d); + args = deserialize1(&d, b->buf+1, b->len-1); // skip 'd' marker + specifier = rb_ary_entry(args, 0); + referrer_url = rb_ary_entry(args, 1); + if (NIL_P(c->dynamic_import_resolver)) + rb_raise(runtime_error, + "import() called but Context#dynamic_import_resolver is not set"); + ret = rb_funcall(c->dynamic_import_resolver, rb_intern("call"), 2, + specifier, referrer_url); + if (!rb_obj_is_kind_of(ret, module_class)) + rb_raise(runtime_error, + "dynamic import resolver must return a MiniRacer::Module, got %s", + rb_obj_classname(ret)); + TypedData_Get_Struct(ret, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "dynamic import resolver returned a disposed Module"); + { + Context *mc; + TypedData_Get_Struct(m->context, Context, &context_type, mc); + if (mc != c) + rb_raise(runtime_error, + "dynamic import resolver returned a Module from a different Context"); + } + return INT2FIX(m->handle_id); +} + +// called with |rr_mtx| and GVL held; |mtx| is unlocked +// request data is in |a->res|, serialized handle id goes in |a->req| +static void *rendezvous_dynamic_import(void *arg) +{ + struct rendezvous_nogvl *a; + const char *err; + Context *c; + int exc; + VALUE r; + Ser s; + + a = arg; + c = a->context; + r = rb_protect(rendezvous_dynamic_import_do, (VALUE)a, &exc); + if (exc) { + c->exception = rb_errinfo(); + rb_set_errinfo(Qnil); + goto fail; + } + ser_init1(&s, 'd'); // dynamic import reply (matches request marker) + if (serialize(&s, r)) { // should not happen + c->exception = rb_exc_new_cstr(internal_error, s.err); + ser_reset(&s); + goto fail; + } +out: + buf_move(&s.b, a->req); + return NULL; +fail: + ser_init0(&s); + w_byte(&s, 'e'); + r = rb_funcall(c->exception, rb_intern("to_s"), 0); + err = StringValueCStr(r); + if (err) + w(&s, err, strlen(err)); + goto out; +} + static void *single_threaded_runner(void *arg) { Context *c; @@ -1058,6 +1262,16 @@ static inline void *rendezvous_nogvl(void *arg) } buf_move(&c->res, a->res); pthread_mutex_unlock(&c->mtx); + if (*a->res->buf == 'm') { // module resolver request? + rb_thread_call_with_gvl(rendezvous_resolve, a); + buf_reset(a->res); + goto next; + } + if (*a->res->buf == 'd') { // dynamic import() request? + rb_thread_call_with_gvl(rendezvous_dynamic_import, a); + buf_reset(a->res); + goto next; + } if (*a->res->buf == 'c') { // js -> ruby callback? rb_thread_call_with_gvl(rendezvous_callback, a); buf_reset(a->res); @@ -1173,6 +1387,8 @@ static VALUE context_alloc(VALUE klass) c = ruby_xmalloc(sizeof(*c)); memset(c, 0, sizeof(*c)); c->exception = Qnil; + c->resolve_block = Qnil; + c->dynamic_import_resolver = Qnil; c->procs = rb_ary_new(); buf_init(&c->snapshot); buf_init(&c->req); @@ -1305,6 +1521,8 @@ static void context_mark(void *arg) c = arg; rb_gc_mark(c->procs); rb_gc_mark(c->exception); + rb_gc_mark(c->resolve_block); + rb_gc_mark(c->dynamic_import_resolver); } static size_t context_size(const void *arg) @@ -1806,11 +2024,267 @@ static VALUE script_error_cause(VALUE self) return rb_iv_get(self, "@cause"); } +static VALUE context_compile_module(int argc, VALUE *argv, VALUE self) +{ + VALUE a, e, source, filename, kwargs, module_v, result; + Module *m; + Context *c; + Ser s; + + TypedData_Get_Struct(self, Context, &context_type, c); + if (atomic_load(&c->quit)) + rb_raise(context_disposed_error, "disposed context"); + rb_scan_args(argc, argv, "1:", &source, &kwargs); + Check_Type(source, T_STRING); + filename = NIL_P(kwargs) ? Qnil : rb_hash_aref(kwargs, ID2SYM(id_filename)); + if (NIL_P(filename)) + filename = rb_str_new_cstr(""); + Check_Type(filename, T_STRING); + ser_init1(&s, 'O'); + ser_array_begin(&s, 2); + add_string(&s, filename); + add_string(&s, source); + ser_array_end(&s, 2); + a = rendezvous(c, &s.b); + e = rb_ary_pop(a); + handle_exception(e); + result = rb_ary_pop(a); + // v8_compile_module replies with the Int32 handle id on success; a + // non-integer would mean the v8 thread fell through to its Undefined + // fail path without an error. Guard rather than feed junk to NUM2INT. + if (!RB_INTEGER_TYPE_P(result)) + rb_raise(internal_error, "compile_module: expected an integer handle id"); + + module_v = rb_obj_alloc(module_class); // skip the raising initialize + TypedData_Get_Struct(module_v, Module, &module_type, m); + m->context = self; + m->handle_id = NUM2INT(result); + return module_v; +} + +static VALUE module_alloc(VALUE klass) +{ + Module *m; + + m = ruby_xmalloc(sizeof(*m)); + memset(m, 0, sizeof(*m)); + m->context = Qnil; + return TypedData_Wrap_Struct(klass, &module_type, m); +} + +static void module_free(void *arg) +{ + // Intentionally does not send a dispose RPC — finalizers can't safely + // take rr_mtx. State::~State() walks st.modules at isolate teardown so + // we leak nothing across a Context's lifetime; use Module#dispose to + // free eagerly mid-lifetime. + ruby_xfree(arg); +} + +static void module_mark(void *arg) +{ + Module *m = arg; + rb_gc_mark(m->context); +} + +static size_t module_size(const void *arg) +{ + (void)arg; + return sizeof(Module); +} + +static VALUE module_initialize(int argc, VALUE *argv, VALUE self) +{ + (void)argc; (void)argv; (void)self; + rb_raise(runtime_error, "MiniRacer::Module must be created via Context#compile_module"); + return Qnil; +} + +struct instantiate_args { + Context *c; + int32_t handle_id; + VALUE prev_block; +}; + +static VALUE module_instantiate_body(VALUE arg) +{ + struct instantiate_args *ia = (struct instantiate_args *)arg; + VALUE a, e; + Ser s; + + ser_init1(&s, 'I'); + ser_int(&s, ia->handle_id); + a = rendezvous(ia->c, &s.b); + e = rb_ary_pop(a); + handle_exception(e); + return Qnil; +} + +static VALUE module_instantiate_restore(VALUE arg) +{ + struct instantiate_args *ia = (struct instantiate_args *)arg; + ia->c->resolve_block = ia->prev_block; + return Qnil; +} + +static VALUE module_instantiate(VALUE self) +{ + VALUE block; + Module *m; + Context *c; + struct instantiate_args ia; + + if (!rb_block_given_p()) + rb_raise(rb_eArgError, "Module#instantiate requires a resolver block"); + block = rb_block_proc(); + + TypedData_Get_Struct(self, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "disposed module"); + TypedData_Get_Struct(m->context, Context, &context_type, c); + if (atomic_load(&c->quit)) + rb_raise(context_disposed_error, "disposed context"); + + // Save the previous resolver slot so a re-entrant instantiate from + // inside this block restores its caller's block on the way out. + // rb_ensure guarantees restoration even when the resolver block + // raises (without it, the slot would be left pointing at this call's + // block and keep it GC-alive until something else overwrites it). + ia.c = c; + ia.handle_id = m->handle_id; + ia.prev_block = c->resolve_block; + c->resolve_block = block; + rb_ensure(module_instantiate_body, (VALUE)&ia, + module_instantiate_restore, (VALUE)&ia); + return self; +} + +static VALUE module_evaluate(VALUE self) +{ + VALUE a, e; + Module *m; + Context *c; + Ser s; + + TypedData_Get_Struct(self, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "disposed module"); + TypedData_Get_Struct(m->context, Context, &context_type, c); + if (atomic_load(&c->quit)) + rb_raise(context_disposed_error, "disposed context"); + ser_init1(&s, 'V'); + ser_int(&s, m->handle_id); + a = rendezvous(c, &s.b); + e = rb_ary_pop(a); + handle_exception(e); + return rb_ary_pop(a); +} + +static VALUE module_namespace(VALUE self) +{ + VALUE a, e; + Module *m; + Context *c; + Ser s; + + TypedData_Get_Struct(self, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "disposed module"); + TypedData_Get_Struct(m->context, Context, &context_type, c); + if (atomic_load(&c->quit)) + rb_raise(context_disposed_error, "disposed context"); + ser_init1(&s, 'N'); + ser_int(&s, m->handle_id); + a = rendezvous(c, &s.b); + e = rb_ary_pop(a); + handle_exception(e); + return rb_ary_pop(a); +} + +static VALUE module_status(VALUE self) +{ + VALUE a, e, result; + Module *m; + Context *c; + Ser s; + + TypedData_Get_Struct(self, Module, &module_type, m); + if (m->disposed) + rb_raise(runtime_error, "disposed module"); + TypedData_Get_Struct(m->context, Context, &context_type, c); + if (atomic_load(&c->quit)) + rb_raise(context_disposed_error, "disposed context"); + ser_init1(&s, 'U'); + ser_int(&s, m->handle_id); + a = rendezvous(c, &s.b); + e = rb_ary_pop(a); + handle_exception(e); + result = rb_ary_pop(a); + // v8_module_status always replies with a String on success; a non-string + // would mean the v8 thread fell through to the Undefined fail path with + // a missing error (shouldn't happen, but check defensively rather than + // crash rb_str_intern on Qnil). + Check_Type(result, T_STRING); + return rb_str_intern(result); +} + +static VALUE module_dispose(VALUE self) +{ + VALUE e; + Module *m; + Context *c; + Ser s; + + TypedData_Get_Struct(self, Module, &module_type, m); + if (m->disposed) return Qnil; + TypedData_Get_Struct(m->context, Context, &context_type, c); + // Context already gone? The handle was cleaned by State::~State(). + if (atomic_load(&c->quit)) { + m->disposed = 1; + return Qnil; + } + ser_init1(&s, 'Z'); + ser_int(&s, m->handle_id); + e = rendezvous(c, &s.b); + handle_exception(e); + // Mark disposed only after the V8 handle is actually freed so a retry + // after a transient rendezvous failure can still release it. + m->disposed = 1; + return Qnil; +} + +static VALUE module_disposed_p(VALUE self) +{ + Module *m; + TypedData_Get_Struct(self, Module, &module_type, m); + return m->disposed ? Qtrue : Qfalse; +} + +static VALUE context_set_dynamic_import_resolver(VALUE self, VALUE blk) +{ + Context *c; + TypedData_Get_Struct(self, Context, &context_type, c); + if (!NIL_P(blk) && !rb_respond_to(blk, rb_intern("call"))) + rb_raise(rb_eTypeError, + "dynamic_import_resolver must respond to #call or be nil"); + c->dynamic_import_resolver = blk; + return blk; +} + +static VALUE context_get_dynamic_import_resolver(VALUE self) +{ + Context *c; + TypedData_Get_Struct(self, Context, &context_type, c); + return c->dynamic_import_resolver; +} + __attribute__((visibility("default"))) void Init_mini_racer_extension(void) { VALUE c, m; + id_filename = rb_intern("filename"); + m = rb_define_module("MiniRacer"); c = rb_define_class_under(m, "Error", rb_eStandardError); snapshot_error = rb_define_class_under(m, "SnapshotError", c); @@ -1830,6 +2304,7 @@ void Init_mini_racer_extension(void) c = context_class = rb_define_class_under(m, "Context", rb_cObject); rb_define_method(c, "initialize", context_initialize, -1); rb_define_method(c, "attach", context_attach, 2); + rb_define_method(c, "compile_module", context_compile_module, -1); rb_define_method(c, "dispose", context_dispose, 0); rb_define_method(c, "stop", context_stop, 0); rb_define_method(c, "call", context_call, -1); @@ -1839,8 +2314,20 @@ void Init_mini_racer_extension(void) rb_define_method(c, "perform_microtask_checkpoint", context_perform_microtask_checkpoint, 0); rb_define_method(c, "pump_message_loop", context_pump_message_loop, 0); rb_define_method(c, "low_memory_notification", context_low_memory_notification, 0); + rb_define_method(c, "dynamic_import_resolver", context_get_dynamic_import_resolver, 0); + rb_define_method(c, "dynamic_import_resolver=", context_set_dynamic_import_resolver, 1); rb_define_alloc_func(c, context_alloc); + c = module_class = rb_define_class_under(m, "Module", rb_cObject); + rb_define_method(c, "initialize", module_initialize, -1); + rb_define_method(c, "instantiate", module_instantiate, 0); + rb_define_method(c, "evaluate", module_evaluate, 0); + rb_define_method(c, "namespace", module_namespace, 0); + rb_define_method(c, "status", module_status, 0); + rb_define_method(c, "dispose", module_dispose, 0); + rb_define_method(c, "disposed?", module_disposed_p, 0); + rb_define_alloc_func(c, module_alloc); + c = snapshot_class = rb_define_class_under(m, "Snapshot", rb_cObject); rb_define_method(c, "initialize", snapshot_initialize, -1); rb_define_method(c, "warmup!", snapshot_warmup, 1); diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 591d5e2..f790ec2 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -3,6 +3,8 @@ #include "libplatform/libplatform.h" #include "mini_racer_v8.h" #include +#include +#include #include #include #include @@ -67,6 +69,18 @@ struct Callback int32_t id; }; +// V8 doesn't expose ScriptOrigin's filename back from a v8::Module +// (UnboundModuleScript only exposes the //# sourceURL magic comment), +// so we cache the filename here at compile time. Used to populate the +// referrer URL passed to the Ruby resolver block and `import.meta.url`. +// v8::Global (not Persistent) so ~ModuleEntry releases the V8 handle +// eagerly — Persistent's default traits skip Reset() in the destructor. +struct ModuleEntry +{ + v8::Global handle; + std::string filename; +}; + // 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 @@ -92,6 +106,10 @@ struct State int err_reason; bool verbose_exceptions; std::vector callbacks; + // Cleared in ~State() under the still-live isolate so each + // ModuleEntry's v8::Global can Reset() before isolate->Dispose(). + std::unordered_map> modules; + int32_t next_module_id; std::unique_ptr allocator; inline ~State(); }; @@ -314,6 +332,189 @@ void v8_gc_callback(v8::Isolate*, v8::GCType, v8::GCCallbackFlags, void *data) } } +// Linear scan of st.modules to map a Local back to the filename +// captured at compile time. Returns empty string if the module isn't ours +// (shouldn't happen — all live modules come from v8_compile_module). +static const std::string& module_filename(State& st, v8::Local mod) +{ + static const std::string empty; + for (auto& kv : st.modules) { + auto stored = v8::Local::New(st.isolate, kv.second->handle); + if (stored == mod) return kv.second->filename; + } + return empty; +} + +// V8 calls this for every JS `import(...)` expression. We rendezvous to +// Ruby (marker 'd'), expect a fully-instantiated MiniRacer::Module back, +// evaluate it if still pending, then resolve the returned Promise with +// its namespace. The contract requires the embedder to handle compile + +// instantiate + evaluate; Ruby's resolver is responsible for the first +// two, and we run Evaluate here so callers don't have to. +static v8::MaybeLocal host_import_module_dynamically_callback( + v8::Local context, + v8::Local /*host_defined_options*/, + v8::Local resource_name, + v8::Local specifier, + v8::Local /*import_attributes*/) +{ + auto isolate = context->GetIsolate(); + State *pst = static_cast(isolate->GetData(0)); + State& st = *pst; + v8::EscapableHandleScope handle_scope(isolate); + + v8::Local resolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) + return v8::MaybeLocal(); + + // Single-exit helpers so every error path is one line. + auto escape = [&] { return handle_scope.Escape(resolver->GetPromise()); }; + auto reject_with_value = [&](v8::Local reason) { + (void)resolver->Reject(context, reason); + return escape(); + }; + // NewFromUtf8Literal returns a Local directly (no allocation Maybe), + // so error messages are safe under isolate OOM where NewFromUtf8 + + // ToLocalChecked would CHECK-fail. + auto reject_with_literal = [&](v8::Local msg) { + return reject_with_value(v8::Exception::Error(msg)); + }; + + v8::Local request; + { + v8::Context::Scope context_scope(st.safe_context); + request = v8::Array::New(st.isolate, 2); + } + request->Set(context, 0, specifier).Check(); + // resource_name is the referrer's filename for module-initiated imports, + // or the script filename for eval-initiated ones. May be Undefined for + // ad-hoc compilations; coerce to empty string in that case. + v8::Local ref = resource_name->IsString() + ? resource_name + : v8::Local::Cast(v8::String::Empty(st.isolate)); + request->Set(context, 1, ref).Check(); + + { + Serialized serialized(st, request); + if (!serialized.data) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "could not serialize dynamic import request")); + uint8_t marker = 'd'; + v8_reply(st.ruby_context, &marker, 1); + v8_reply(st.ruby_context, serialized.data, serialized.size); + } + + const uint8_t *p; + size_t n; + for (;;) { + v8_roundtrip(st.ruby_context, &p, &n); + if (*p == 'd') break; + if (*p == 'e') { + v8::Local message; + auto type = v8::NewStringType::kNormal; + if (!v8::String::NewFromOneByte(st.isolate, p+1, type, n-1).ToLocal(&message)) + message = v8::String::NewFromUtf8Literal(st.isolate, "Ruby exception"); + return reject_with_literal(message); + } + v8_dispatch(st.ruby_context); + } + + v8::ValueDeserializer des(st.isolate, p+1, n-1); + des.ReadHeader(st.context).Check(); + v8::Local id_v; + int32_t id; + if (!des.ReadValue(st.context).ToLocal(&id_v) || + !id_v->Int32Value(st.context).To(&id)) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import reply could not be decoded")); + auto it = st.modules.find(id); + if (it == st.modules.end()) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import resolver returned a handle unknown to this Context")); + auto module = v8::Local::New(st.isolate, it->second->handle); + + auto status = module->GetStatus(); + // The Ruby resolver must hand back a Module that's at least instantiated; + // auto-instantiating here is impossible because there's no per-call + // resolver block to recurse through. + if (status < v8::Module::kInstantiated) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import resolver returned an uninstantiated Module")); + if (status == v8::Module::kErrored) + return reject_with_value(module->GetException()); + // kEvaluating means re-entry during cyclic dynamic import: V8 would + // give us a TDZ-laden namespace whose bindings throw ReferenceError. + // Spec-correct handling is to settle after the in-flight Evaluate + // completes, which requires TLA support; reject explicitly for now. + if (status == v8::Module::kEvaluating) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import target is mid-evaluation (cyclic dynamic import)")); + if (status == v8::Module::kInstantiated) { + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::Local eval_result; + if (!module->Evaluate(context).ToLocal(&eval_result)) { + // Termination set the empty MaybeLocal without throwing — let + // the surrounding eval frame surface it instead of swallowing. + if (isolate->IsExecutionTerminating()) + return v8::MaybeLocal(); + return reject_with_value(try_catch.HasCaught() + ? try_catch.Exception() + : v8::Local::Cast(v8::Undefined(isolate))); + } + // Drain so synchronously-scheduled microtasks (e.g. the dep body's + // own Promise.resolve().then) settle before we inspect promise state; + // matches v8_evaluate_module. + isolate->PerformMicrotaskCheckpoint(); + if (eval_result->IsPromise()) { + auto promise = eval_result.As(); + if (promise->State() == v8::Promise::kRejected) + return reject_with_value(promise->Result()); + if (promise->State() == v8::Promise::kPending) + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import target has top-level await (not supported)")); + } + } else if (status == v8::Module::kEvaluated && module->IsGraphAsync()) { + // The resolver handed back an already-evaluated module. We confirmed + // settlement above only for the just-evaluated (kInstantiated) branch; + // a previously-evaluated async module may still have a pending + // top-level await whose TDZ namespace would fatally abort. Refuse it. + return reject_with_literal(v8::String::NewFromUtf8Literal(isolate, + "dynamic import target uses top-level await (not supported)")); + } + + (void)resolver->Resolve(context, module->GetModuleNamespace()); + return escape(); +} + +// V8 calls this the first time JS reads `import.meta` for a module. +// Populate the `url` property with the filename passed to compile_module +// — needed for relative resolution helpers like `new URL(spec, import.meta.url)`. +static void init_import_meta_object(v8::Local context, + v8::Local module, + v8::Local meta) +{ + auto isolate = context->GetIsolate(); + // module_filename() materializes a Local per entry while scanning; + // give them a scope to reclaim instead of piling onto the caller's. + v8::HandleScope handle_scope(isolate); + State *pst = static_cast(isolate->GetData(0)); + const std::string& filename = module_filename(*pst, module); + // Pass the byte length explicitly: filenames may contain embedded NULs, + // and NewFromUtf8 without a length argument truncates at the first NUL. + v8::Local name; + auto type = v8::NewStringType::kNormal; + if (!v8::String::NewFromUtf8(isolate, filename.data(), type, + static_cast(filename.size())).ToLocal(&name)) + return; + auto key = v8::String::NewFromUtf8Literal(isolate, "url"); + // Do not Check() — a user-installed setter on Object.prototype.url + // would throw, and Check() would abort the process. Letting the + // Maybe drop surfaces the failure as a JS exception via the + // surrounding TryCatch frame. + (void)meta->Set(context, key, name); +} + extern "C" State *v8_thread_init(Context *c, const uint8_t *snapshot_buf, size_t snapshot_len, int64_t max_memory, int verbose_exceptions) @@ -332,6 +533,14 @@ extern "C" State *v8_thread_init(Context *c, const uint8_t *snapshot_buf, params.snapshot_blob = &blob; } st.isolate = v8::Isolate::New(params); + // Slot 0 lets v8 callbacks that don't take embedder data (notably + // Module::InstantiateModule's ResolveCallback) recover State. + st.isolate->SetData(0, pst); + // Populate `import.meta.url` with the filename passed to compile_module. + st.isolate->SetHostInitializeImportMetaObjectCallback(init_import_meta_object); + // Dispatch JS `import(...)` expressions to Ruby via marker 'd'. + st.isolate->SetHostImportModuleDynamicallyCallback( + host_import_module_dynamically_callback); st.max_memory = max_memory; if (st.max_memory > 0) st.isolate->AddGCEpilogueCallback(v8_gc_callback, pst); @@ -606,6 +815,452 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) } } +// Pulls a Module handle id out of the request, looks it up in st.modules, +// and stores the Local in *out. On miss, sets *cause = RUNTIME_ERROR and +// throws a V8 exception; on deserialization failure, leaves *cause alone +// and lets the standard fail-path handler take over. Returns false in +// either failure case so callers can `goto fail` consistently. +static bool module_from_request(State& st, + v8::ValueDeserializer& des, + v8::Local* out, + int* cause) +{ + v8::Local id_v; + if (!des.ReadValue(st.context).ToLocal(&id_v)) return false; + int32_t id; + if (!id_v->Int32Value(st.context).To(&id)) return false; + auto it = st.modules.find(id); + if (it == st.modules.end()) { + *cause = RUNTIME_ERROR; + auto msg = v8::String::NewFromUtf8Literal(st.isolate, "no such module handle"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + return false; + } + *out = v8::Local::New(st.isolate, it->second->handle); + return true; +} + +// request: [filename, source] +// response: errback [handle_id:Int32, err] +// +// Parses |source| as an ES module. handle_id keys st.modules for later +// v8_instantiate_module / v8_evaluate_module / v8_module_namespace / +// v8_dispose_module. Imports declared by the module are not resolved here +// — that happens in v8_instantiate_module via a Ruby-provided resolver. +extern "C" void v8_compile_module(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local result; + int cause = INTERNAL_ERROR; + { + v8::Local request_v; + if (!des.ReadValue(st.context).ToLocal(&request_v)) goto fail; + v8::Local request; + if (!request_v->ToObject(st.context).ToLocal(&request)) goto fail; + v8::Local filename; + if (!request->Get(st.context, 0).ToLocal(&filename)) goto fail; + v8::Local source_v; + if (!request->Get(st.context, 1).ToLocal(&source_v)) goto fail; + v8::Local source; + if (!source_v->ToString(st.context).ToLocal(&source)) goto fail; + + // is_module must be true on the ScriptOrigin for V8 to accept + // import/export syntax. + v8::ScriptOrigin origin(filename, + /*resource_line_offset=*/0, + /*resource_column_offset=*/0, + /*resource_is_shared_cross_origin=*/false, + /*script_id=*/-1, + /*source_map_url=*/v8::Local(), + /*resource_is_opaque=*/false, + /*is_wasm=*/false, + /*is_module=*/true); + v8::ScriptCompiler::Source source_obj(source, origin); + v8::Local module; + cause = PARSE_ERROR; + if (!v8::ScriptCompiler::CompileModule(st.isolate, &source_obj) + .ToLocal(&module)) goto fail; + cause = INTERNAL_ERROR; + + // Ids are monotonic and serialized as Int32 on the wire. Refuse to + // wrap rather than invoke signed-overflow UB and risk aliasing a + // still-live handle id (unreachable in practice — each live module + // pins a Global handle, so the isolate OOMs long before 2^31). + if (st.next_module_id == INT32_MAX) { + cause = INTERNAL_ERROR; + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module id space exhausted for this Context"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + goto fail; + } + int32_t id = ++st.next_module_id; + auto entry = std::make_unique(); + entry->handle.Reset(st.isolate, module); + v8::String::Utf8Value fname(st.isolate, filename); + if (*fname) entry->filename.assign(*fname, fname.length()); + st.modules[id] = std::move(entry); + result = v8::Int32::New(st.isolate, id); + } + 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; + } + if (bubble_up_ruby_exception(st, &try_catch)) return; + if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; + if (result.IsEmpty()) result = v8::Undefined(st.isolate); + auto err = to_error(st, &try_catch, cause); + if (!reply(st, result, err)) { + assert(try_catch.HasCaught()); + goto fail; + } +} + +// V8 invokes this for each static import while InstantiateModule walks +// the import graph. It has no embedder slot, so State is recovered via +// isolate->GetData(0). We round-trip to Ruby with marker 'm', expect an +// int32 handle id back, and look it up in st.modules. +// +// The Ruby resolver block can re-enter the v8 thread via other dispatch +// tags (e.g. compile_module the requested module on demand) — that flows +// through v8_dispatch inside the wait loop, like v8_api_callback does. +static v8::MaybeLocal resolve_module_callback( + v8::Local context, + v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local referrer) +{ + v8::Isolate *isolate = context->GetIsolate(); + State *pst = static_cast(isolate->GetData(0)); + State& st = *pst; + + // InstantiateModule walks the entire import graph in one call; without + // an explicit scope, every Local allocated per import (request, dispatch + // buffers, transitive compile_module Locals) would pile into whatever + // outer scope the embedder installed. EscapableHandleScope so the + // returned Local survives the scope's destruction. + v8::EscapableHandleScope handle_scope(isolate); + + v8::Local request; + { + v8::Context::Scope context_scope(st.safe_context); + request = v8::Array::New(st.isolate, 2); + } + // Use the callback's |context| (matches what V8 walked the graph in) + // rather than st.context. In mini_racer's single-context-per-isolate + // model they're the same handle, but this is defensive in case that + // ever changes. + request->Set(context, 0, specifier).Check(); + // Referrer URL — the filename passed to compile_module's filename: + // kwarg. Lets the Ruby resolver resolve relative specifiers + // (`./foo`, `../bar`) against the importing module. Falls back to + // an empty string if we can't materialize the v8::String (OOM). + // Pass length explicitly so embedded NULs in the filename survive. + v8::Local referrer_name; + v8::Local s; + const std::string& ref_fn = module_filename(st, referrer); + auto type = v8::NewStringType::kNormal; + if (v8::String::NewFromUtf8(st.isolate, ref_fn.data(), type, + static_cast(ref_fn.size())).ToLocal(&s)) { + referrer_name = s; + } else { + referrer_name = v8::String::Empty(st.isolate); + } + request->Set(context, 1, referrer_name).Check(); + { + Serialized serialized(st, request); + if (!serialized.data) return v8::MaybeLocal(); + uint8_t marker = 'm'; + v8_reply(st.ruby_context, &marker, 1); + v8_reply(st.ruby_context, serialized.data, serialized.size); + } + const uint8_t *p; + size_t n; + for (;;) { + v8_roundtrip(st.ruby_context, &p, &n); + if (*p == 'm') break; + if (*p == 'e') { + v8::Local message; + auto type = v8::NewStringType::kNormal; + if (!v8::String::NewFromOneByte(st.isolate, p+1, type, n-1).ToLocal(&message)) { + message = v8::String::NewFromUtf8Literal(st.isolate, "Ruby exception"); + } + auto exception = v8::Exception::Error(message); + st.ruby_exception.Reset(st.isolate, exception); + st.isolate->ThrowException(exception); + return v8::MaybeLocal(); + } + v8_dispatch(st.ruby_context); + } + v8::ValueDeserializer des(st.isolate, p+1, n-1); + des.ReadHeader(st.context).Check(); + v8::Local id_v; + if (!des.ReadValue(st.context).ToLocal(&id_v)) return v8::MaybeLocal(); + int32_t id; + if (!id_v->Int32Value(st.context).To(&id)) return v8::MaybeLocal(); + auto it = st.modules.find(id); + if (it == st.modules.end()) { + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module resolver returned a handle unknown to this Context"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + return v8::MaybeLocal(); + } + return handle_scope.Escape(v8::Local::New(st.isolate, + it->second->handle)); +} + +// request: [handle_id:Int32] +// response: errback [undefined, err] +extern "C" void v8_instantiate_module(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local result; + int cause = INTERNAL_ERROR; + { + v8::Local module; + if (!module_from_request(st, des, &module, &cause)) goto fail; + cause = RUNTIME_ERROR; + v8::Maybe ok = module->InstantiateModule(st.context, resolve_module_callback); + if (ok.IsNothing() || !ok.FromJust()) goto fail; + result = v8::Undefined(st.isolate); + } + 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; + } + if (bubble_up_ruby_exception(st, &try_catch)) return; + if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; + if (result.IsEmpty()) result = v8::Undefined(st.isolate); + auto err = to_error(st, &try_catch, cause); + if (!reply(st, result, err)) { + assert(try_catch.HasCaught()); + goto fail; + } +} + +// request: [handle_id:Int32] +// response: errback [evaluation_result, err] +// +// V8 wraps every module evaluation in a Promise (settles synchronously for +// non-TLA modules). We drain microtasks once, then unwrap. Pending after +// the drain means the module has top-level await still in flight — not +// supported in this round; the user gets a clear error. +extern "C" void v8_evaluate_module(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local result; + int cause = INTERNAL_ERROR; + { + v8::Local module; + if (!module_from_request(st, des, &module, &cause)) goto fail; + // V8 requires status >= kInstantiated for Evaluate; calling on an + // uninstantiated module hits a CHECK and aborts the process. + if (module->GetStatus() < v8::Module::kInstantiated) { + cause = RUNTIME_ERROR; + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module must be instantiated before it can be evaluated"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + goto fail; + } + cause = RUNTIME_ERROR; + v8::Local eval_result; + if (!module->Evaluate(st.context).ToLocal(&eval_result)) goto fail; + st.isolate->PerformMicrotaskCheckpoint(); + if (!eval_result->IsPromise()) { + // older V8 / unusual configurations may return a plain value + result = sanitize(st, eval_result); + } else { + auto promise = eval_result.As(); + if (promise->State() == v8::Promise::kFulfilled) { + result = sanitize(st, promise->Result()); + } else if (promise->State() == v8::Promise::kRejected) { + st.isolate->ThrowException(promise->Result()); + goto fail; + } else { + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module evaluation is still pending " + "(top-level await is not yet supported)"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + goto fail; + } + } + } + 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; + } + if (bubble_up_ruby_exception(st, &try_catch)) return; + if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; + if (result.IsEmpty()) result = v8::Undefined(st.isolate); + auto err = to_error(st, &try_catch, cause); + if (!reply(st, result, err)) { + assert(try_catch.HasCaught()); + goto fail; + } +} + +// request: [handle_id:Int32] +// response: errback [namespace_value, err] +// +// Only a fully evaluated, non-async module has a safe-to-read namespace. +// Reading the namespace of a module whose export bindings are still in the +// temporal dead zone (not yet evaluated, or a top-level-await module whose +// promise never settled) makes the serializer hit a throwing accessor on +// every property, which V8 turns into an unrecoverable FatalProcessOutOfMemory +// (process abort), not a catchable exception. So require kEvaluated AND +// !IsGraphAsync(), surface an errored module's own exception, and reject every +// other state with a clear error. Plain-data exports come back as Hash entries +// via the regular sanitize path; function exports are filtered out by the +// safe-context wrapper, same as other Object returns. +extern "C" void v8_module_namespace(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local result; + int cause = INTERNAL_ERROR; + { + v8::Local module; + if (!module_from_request(st, des, &module, &cause)) goto fail; + auto status = module->GetStatus(); + if (status == v8::Module::kErrored) { + cause = RUNTIME_ERROR; + st.isolate->ThrowException(module->GetException()); + goto fail; + } + if (status != v8::Module::kEvaluated) { + cause = RUNTIME_ERROR; + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module must be evaluated before its namespace can be read"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + goto fail; + } + // kEvaluated is necessary but NOT sufficient: V8 sets kEvaluated as + // soon as Evaluate() returns, even for a top-level-await module whose + // promise never settled, leaving the export bindings in the TDZ. + // IsGraphAsync() is false exactly when the evaluation promise settled + // synchronously (per V8's Evaluate contract), so a non-async kEvaluated + // module — including one evaluated only transitively as a dependency — + // is the one case whose namespace is safe to read. Refuse async graphs; + // reading their TDZ bindings would fatally abort the process. + if (module->IsGraphAsync()) { + cause = RUNTIME_ERROR; + auto msg = v8::String::NewFromUtf8Literal(st.isolate, + "module namespace is unavailable: the module (or a dependency) " + "uses top-level await, which is not supported"); + st.isolate->ThrowException(v8::Exception::Error(msg)); + goto fail; + } + cause = RUNTIME_ERROR; + result = sanitize(st, module->GetModuleNamespace()); + } + 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; + } + if (bubble_up_ruby_exception(st, &try_catch)) return; + if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; + if (result.IsEmpty()) result = v8::Undefined(st.isolate); + auto err = to_error(st, &try_catch, cause); + if (!reply(st, result, err)) { + assert(try_catch.HasCaught()); + goto fail; + } +} + +// response: errback [status_name:String, err] +// status_name is one of the v8::Module::Status enum names in lowercase +// ("uninstantiated", "instantiating", "instantiated", "evaluating", +// "evaluated", "errored"). Ruby side converts to a symbol. +extern "C" void v8_module_status(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::TryCatch try_catch(st.isolate); + try_catch.SetVerbose(st.verbose_exceptions); + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local result; + int cause = INTERNAL_ERROR; + { + v8::Local module; + if (!module_from_request(st, des, &module, &cause)) goto fail; + const char *name; + switch (module->GetStatus()) { + case v8::Module::kUninstantiated: name = "uninstantiated"; break; + case v8::Module::kInstantiating: name = "instantiating"; break; + case v8::Module::kInstantiated: name = "instantiated"; break; + case v8::Module::kEvaluating: name = "evaluating"; break; + case v8::Module::kEvaluated: name = "evaluated"; break; + case v8::Module::kErrored: name = "errored"; break; + default: name = "unknown"; break; + } + v8::Local s; + if (!v8::String::NewFromUtf8(st.isolate, name).ToLocal(&s)) goto fail; + result = s; + } + 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; + } + if (bubble_up_ruby_exception(st, &try_catch)) return; + if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; + if (result.IsEmpty()) result = v8::Undefined(st.isolate); + auto err = to_error(st, &try_catch, cause); + if (!reply(st, result, err)) { + assert(try_catch.HasCaught()); + goto fail; + } +} + +// Unknown ids are silently ignored — Ruby-side Module#dispose is idempotent. +extern "C" void v8_dispose_module(State *pst, const uint8_t *p, size_t n) +{ + State& st = *pst; + v8::HandleScope handle_scope(st.isolate); + v8::ValueDeserializer des(st.isolate, p, n); + des.ReadHeader(st.context).Check(); + v8::Local id_v; + if (des.ReadValue(st.context).ToLocal(&id_v)) { + int32_t id; + if (id_v->Int32Value(st.context).To(&id)) + st.modules.erase(id); + } + reply_retry(st, v8::String::Empty(st.isolate)); +} + extern "C" void v8_heap_stats(State *pst) { State& st = *pst; @@ -931,6 +1586,7 @@ State::~State() { v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); + modules.clear(); persistent_safe_context.Reset(); persistent_context.Reset(); ruby_exception.Reset(); diff --git a/ext/mini_racer_extension/mini_racer_v8.h b/ext/mini_racer_extension/mini_racer_v8.h index 57f12fb..2ee8afd 100644 --- a/ext/mini_racer_extension/mini_racer_v8.h +++ b/ext/mini_racer_extension/mini_racer_v8.h @@ -39,6 +39,12 @@ 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_compile_module(struct State *pst, const uint8_t *p, size_t n); +void v8_dispose_module(struct State *pst, const uint8_t *p, size_t n); +void v8_evaluate_module(struct State *pst, const uint8_t *p, size_t n); +void v8_instantiate_module(struct State *pst, const uint8_t *p, size_t n); +void v8_module_namespace(struct State *pst, const uint8_t *p, size_t n); +void v8_module_status(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_heap_stats(struct State *pst); void v8_heap_snapshot(struct State *pst); diff --git a/lib/mini_racer/truffleruby.rb b/lib/mini_racer/truffleruby.rb index 8a40048..fb1560b 100644 --- a/lib/mini_racer/truffleruby.rb +++ b/lib/mini_racer/truffleruby.rb @@ -388,4 +388,40 @@ def warmup_unsafe!(src) self end end + + # GraalJS has its own module-loading mechanism that doesn't map onto the + # handle-based MiniRacer::Module API; surface a clear error rather than + # a NoMethodError so callers know to gate the feature behind an engine + # check. The Module class itself is stubbed so cross-engine code can + # still reference MiniRacer::Module for is_a? / rescue clauses without + # tripping NameError. + class Context + def compile_module(*_args, **_opts) + raise NotImplementedError, + 'Context#compile_module is not supported on TruffleRuby' + end + + # nil is the documented "disable" value; accept it as a no-op so that + # `ctx.dynamic_import_resolver ||= ...` style code doesn't crash on + # TruffleRuby. Any callable raises, mirroring `compile_module`. + def dynamic_import_resolver=(blk) + return blk if blk.nil? + raise NotImplementedError, + 'Context#dynamic_import_resolver= is not supported on TruffleRuby' + end + + def dynamic_import_resolver = nil + end + + class Module + UNSUPPORTED = 'MiniRacer::Module is not supported on TruffleRuby' + + def initialize(*) = raise(NotImplementedError, UNSUPPORTED) + def instantiate(*, &_blk) = raise(NotImplementedError, UNSUPPORTED) + def evaluate = raise(NotImplementedError, UNSUPPORTED) + def namespace = raise(NotImplementedError, UNSUPPORTED) + def status = raise(NotImplementedError, UNSUPPORTED) + def dispose = raise(NotImplementedError, UNSUPPORTED) + def disposed? = raise(NotImplementedError, UNSUPPORTED) + end end diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index a9ffe38..f9dba10 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1275,4 +1275,477 @@ def test_exception_message_encoding assert e assert_equal(e.message.encoding.to_s, "UTF-8") end + + # -- ES module API (Context#compile_module / MiniRacer::Module) -- + + def skip_on_truffleruby_module + skip("TruffleRuby has no equivalent ES module handle") if RUBY_ENGINE == "truffleruby" + end + + def test_compile_module_returns_handle + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + assert_kind_of MiniRacer::Module, mod + refute_predicate mod, :disposed? + end + + def test_compile_module_parse_error + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + assert_raises(MiniRacer::ParseError) do + ctx.compile_module("this is not valid", filename: "bad.js") + end + end + + def test_compile_module_accepts_import_declaration + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("import { x } from 'other'; export const y = x + 1", + filename: "imp.js") + assert_kind_of MiniRacer::Module, mod + end + + def test_module_new_direct_raises + skip_on_truffleruby_module + assert_raises(MiniRacer::RuntimeError) { MiniRacer::Module.new } + end + + def test_module_dispose_idempotent + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + mod.dispose + assert_predicate mod, :disposed? + assert_nil mod.dispose + end + + def test_module_after_context_dispose + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + ctx.dispose + assert_raises(MiniRacer::ContextDisposedError) { mod.status } + assert_nil mod.dispose + end + + def test_module_status_starts_uninstantiated + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + assert_equal :uninstantiated, mod.status + end + + def test_module_status_after_dispose_raises + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + mod.dispose + assert_raises(MiniRacer::RuntimeError) { mod.status } + end + + def test_module_instantiate_no_imports_skips_resolver + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + mod.instantiate { raise "resolver should not be called" } + assert_equal :instantiated, mod.status + end + + def test_module_instantiate_calls_resolver_with_specifier + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module("export const y = 7", filename: "dep.js") + main = ctx.compile_module("import { y } from 'dep'; export const z = y * 2", + filename: "main.js") + seen = [] + main.instantiate {|spec| seen << spec; dep } + assert_equal ["dep"], seen + assert_equal :instantiated, main.status + assert_equal :instantiated, dep.status + end + + def test_module_instantiate_requires_block + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + assert_raises(ArgumentError) { mod.instantiate } + end + + def test_module_instantiate_resolver_returning_non_module_raises + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("import 'foo'", filename: "m.js") + err = assert_raises(MiniRacer::RuntimeError) do + mod.instantiate {|_spec| "not a module" } + end + assert_includes err.message, "MiniRacer::Module" + end + + def test_module_instantiate_resolver_exception_bubbles_original_class + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("import 'foo'", filename: "m.js") + err = assert_raises(ArgumentError) do + mod.instantiate {|_spec| raise ArgumentError, "deliberate" } + end + assert_equal "deliberate", err.message + end + + def test_module_instantiate_resolves_transitively + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + a = ctx.compile_module("export const a = 1", filename: "a.js") + b = ctx.compile_module("import { a } from 'a'; export const b = a + 1", filename: "b.js") + c = ctx.compile_module("import { b } from 'b'; export const c = b + 1", filename: "c.js") + table = { "a" => a, "b" => b } + seen = [] + c.instantiate {|spec| seen << spec; table.fetch(spec) } + assert_equal ["a", "b"], seen.sort + [a, b, c].each {|m| assert_equal :instantiated, m.status } + end + + def test_module_instantiate_passes_referrer_url_to_resolver + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module("export const y = 1", filename: "lib/dep.js") + main = ctx.compile_module("import { y } from './dep'; export const z = y", + filename: "lib/main.js") + seen = [] + main.instantiate {|spec, referrer| + seen << [spec, referrer] + dep + } + assert_equal [['./dep', 'lib/main.js']], seen + end + + def test_module_import_meta_url_is_populated + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("globalThis.metaUrl = import.meta.url", + filename: 'src/entry.js') + mod.instantiate { raise 'resolver should not be called' } + mod.evaluate + assert_equal 'src/entry.js', ctx.eval('globalThis.metaUrl') + end + + def test_module_filename_with_embedded_nul_survives_roundtrip + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + name = "a\0b.js" + mod = ctx.compile_module('globalThis.url = import.meta.url', filename: name) + mod.instantiate { raise 'resolver should not be called' } + mod.evaluate + assert_equal name, ctx.eval('globalThis.url') + end + + def test_module_evaluate_before_instantiate_raises + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module('export const x = 1', filename: 'a.js') + err = assert_raises(MiniRacer::RuntimeError) { mod.evaluate } + assert_includes err.message, 'must be instantiated' # not "uninstantiated" + end + + def test_compile_module_default_filename + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module('globalThis.metaUrl = import.meta.url') + mod.instantiate { raise 'resolver should not be called' } + mod.evaluate + assert_equal '', ctx.eval('globalThis.metaUrl') + end + + def test_module_resolver_rejects_module_from_other_context + skip_on_truffleruby_module + ctx_a = MiniRacer::Context.new + ctx_b = MiniRacer::Context.new + foreign = ctx_a.compile_module('export const x = 1', filename: 'a.js') + main = ctx_b.compile_module("import 'foo'", filename: 'main.js') + err = assert_raises(MiniRacer::RuntimeError) do + main.instantiate {|_spec, _ref| foreign } + end + assert_includes err.message, 'different Context' + end + + def test_dynamic_import_resolver_resolves_with_namespace + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module('export const x = 42', filename: 'dep.js') + dep.instantiate { raise 'resolver should not be called' } + dep.evaluate + seen = [] + ctx.dynamic_import_resolver = ->(spec, ref) { + seen << [spec, ref] + dep + } + ctx.eval("import('dep').then(ns => { globalThis.r = ns.x })", + filename: 'caller.js') + ctx.perform_microtask_checkpoint + assert_equal [['dep', 'caller.js']], seen + assert_equal 42, ctx.eval('globalThis.r') + end + + def test_dynamic_import_without_resolver_rejects + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + ctx.eval(<<~JS, filename: 'main.js') + import('foo').catch(e => { globalThis.err = String(e) }) + JS + ctx.perform_microtask_checkpoint + assert_match(/dynamic_import_resolver/, ctx.eval('globalThis.err')) + end + + def test_dynamic_import_resolver_rejects_module_from_other_context + skip_on_truffleruby_module + ctx_a = MiniRacer::Context.new + ctx_b = MiniRacer::Context.new + foreign = ctx_a.compile_module('export const x = 1', filename: 'a.js') + foreign.instantiate { raise 'noop' } + foreign.evaluate + ctx_b.dynamic_import_resolver = ->(_spec, _ref) { foreign } + ctx_b.eval(<<~JS, filename: 'main.js') + import('foo').catch(e => { globalThis.err = String(e) }) + JS + ctx_b.perform_microtask_checkpoint + assert_match(/different Context/, ctx_b.eval('globalThis.err')) + end + + def test_dynamic_import_resolver_auto_evaluates_pending_module + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module('globalThis.evaled = (globalThis.evaled||0)+1; export const x = 7', + filename: 'dep.js') + dep.instantiate { raise 'noop' } + # Note: we do NOT call dep.evaluate here — the dynamic import path must drive it. + ctx.dynamic_import_resolver = ->(_spec, _ref) { dep } + ctx.eval("import('dep').then(ns => { globalThis.r = ns.x })", + filename: 'caller.js') + ctx.perform_microtask_checkpoint + assert_equal 7, ctx.eval('globalThis.r') + assert_equal 1, ctx.eval('globalThis.evaled') + end + + def test_dynamic_import_auto_evaluate_drains_module_body_microtasks + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module(<<~JS, filename: 'dep.js') + globalThis.t = 0; + Promise.resolve().then(() => { globalThis.t = 1 }); + export const x = 1; + JS + dep.instantiate { raise 'noop' } + # Do NOT call dep.evaluate — the dynamic import path drives evaluation, + # and must drain microtasks so the module-body .then settles before we + # resolve the outer import() Promise. + ctx.dynamic_import_resolver = ->(_s, _r) { dep } + ctx.eval("import('dep').then(() => { globalThis.observed = globalThis.t })", + filename: 'caller.js') + ctx.perform_microtask_checkpoint + assert_equal 1, ctx.eval('globalThis.observed') + end + + def test_dynamic_import_resolver_setter_type_check + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + assert_raises(TypeError) { ctx.dynamic_import_resolver = 42 } + ctx.dynamic_import_resolver = nil + assert_nil ctx.dynamic_import_resolver + blk = ->(_s, _r) { nil } + ctx.dynamic_import_resolver = blk + assert_equal blk, ctx.dynamic_import_resolver + end + + def test_module_dispose_releases_handles_eagerly + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + before = ctx.heap_stats[:used_heap_size] + 1000.times do |i| + m = ctx.compile_module("export const x = #{i}", filename: "m#{i}.js") + m.dispose + end + ctx.low_memory_notification + after = ctx.heap_stats[:used_heap_size] + assert_operator after - before, :<, 2_000_000, + "expected eager dispose to keep heap growth bounded (was #{after - before} bytes)" + end + + def test_module_instantiate_resolver_can_compile_lazily + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + sources = { + "tip" => "import { mid } from 'mid'; export const tip = mid + 'tip'", + "mid" => "import { base } from 'base'; export const mid = base + 'mid'", + "base" => "export const base = 'base'", + } + cache = {} + tip = ctx.compile_module(sources["tip"], filename: "tip.js") + tip.instantiate {|spec| + cache[spec] ||= ctx.compile_module(sources.fetch(spec), filename: "#{spec}.js") + } + assert_equal ["base", "mid"], cache.keys.sort + assert_equal :instantiated, tip.status + end + + def test_module_evaluate_returns_undefined_for_simple_module + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 42", filename: "a.js") + mod.instantiate { raise "resolver should not be called" } + assert_nil mod.evaluate + end + + def test_module_evaluate_runtime_error_propagates + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("throw new Error('boom')", filename: "bad.js") + mod.instantiate { raise "resolver should not be called" } + err = assert_raises(MiniRacer::RuntimeError) { mod.evaluate } + assert_includes err.message, "boom" + end + + def test_module_evaluate_top_level_await_unsupported + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + # A never-settling await keeps the evaluation promise genuinely pending + # after the microtask drain, exercising the top-level-await detection + # branch (an awaited setTimeout would instead reject — setTimeout is + # undefined — and never reach that branch). + tla = ctx.compile_module( + "await new Promise(() => {}); export const x = 1", + filename: "tla.js") + tla.instantiate { raise "resolver should not be called" } + err = assert_raises(MiniRacer::RuntimeError) { tla.evaluate } + assert_includes err.message, "top-level await" + end + + def test_module_namespace_data_exports + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module( + "export const a = 1; export const b = 'two'; export const c = [3, 4]", + filename: "a.js") + mod.instantiate { raise "resolver should not be called" } + mod.evaluate + assert_equal({"a" => 1, "b" => "two", "c" => [3, 4]}, mod.namespace) + end + + def test_module_namespace_includes_default_export + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module( + "const v = { kind: 'obj', n: 7 }; export default v", + filename: "d.js") + mod.instantiate { raise "resolver should not be called" } + mod.evaluate + assert_equal({"default" => {"kind" => "obj", "n" => 7}}, mod.namespace) + end + + def test_module_namespace_before_instantiate_raises + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + assert_raises(MiniRacer::RuntimeError) { mod.namespace } + end + + def test_module_namespace_before_evaluate_raises + # Reading the namespace of an instantiated-but-unevaluated module used to + # abort the whole process (fatal V8 OOM as the TDZ export accessors recurse + # through the serializer). It must raise a catchable error instead. + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + mod.instantiate { raise "resolver should not be called" } + assert_equal :instantiated, mod.status + err = assert_raises(MiniRacer::RuntimeError) { mod.namespace } + assert_includes err.message, "evaluated" + # Context/V8 thread survived (did not abort). + assert_kind_of MiniRacer::Module, ctx.compile_module("export const y = 2", filename: "y.js") + end + + def test_module_namespace_on_errored_module_reraises + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("throw new Error('boom')", filename: "bad.js") + mod.instantiate { raise "resolver should not be called" } + assert_raises(MiniRacer::RuntimeError) { mod.evaluate } + assert_equal :errored, mod.status + err = assert_raises(MiniRacer::RuntimeError) { mod.namespace } + assert_includes err.message, "boom" + end + + def test_module_namespace_on_pending_top_level_await_raises + # A top-level-await module reaches status :evaluated as soon as Evaluate + # returns its (still-pending) promise, so the kEvaluated gate alone is not + # enough — reading its TDZ namespace used to abort the process. The async + # graph must be refused with a catchable error. + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("await new Promise(() => {}); export const x = 1", + filename: "tla.js") + mod.instantiate { raise "resolver should not be called" } + assert_raises(MiniRacer::RuntimeError) { mod.evaluate } + assert_equal :evaluated, mod.status # V8 marks it evaluated despite pending await + err = assert_raises(MiniRacer::RuntimeError) { mod.namespace } + assert_includes err.message, "top-level await" + # Context/V8 thread survived (did not abort). + assert_kind_of MiniRacer::Module, ctx.compile_module("export const y = 2", filename: "y.js") + end + + def test_module_namespace_of_transitively_evaluated_dependency + # Evaluating the importer evaluates its dependencies too; the dependency's + # namespace must be readable even though dep.evaluate was never called + # directly (it is a synchronous, fully-evaluated module). + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module("export const base = 10", filename: "dep.js") + main = ctx.compile_module("import { base } from 'dep'; export const doubled = base * 2", + filename: "main.js") + main.instantiate { dep } + main.evaluate # evaluates dep transitively; dep.evaluate is NOT called + assert_equal({"base" => 10}, dep.namespace) + assert_equal({"doubled" => 20}, main.namespace) + end + + def test_module_status_transitions + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const v = 5", filename: "a.js") + assert_equal :uninstantiated, mod.status + mod.instantiate { raise "resolver should not be called" } + assert_equal :instantiated, mod.status + mod.evaluate + assert_equal :evaluated, mod.status + end + + def test_module_imports_provide_runtime_bindings + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + dep = ctx.compile_module("export const base = 10", filename: "dep.js") + main = ctx.compile_module( + "import { base } from 'dep'; export const doubled = base * 2", + filename: "main.js") + main.instantiate {|spec| dep } + dep.evaluate + main.evaluate + assert_equal 20, main.namespace["doubled"] + end + + def test_module_evaluate_idempotent + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + mod = ctx.compile_module("export const x = 1", filename: "a.js") + mod.instantiate { raise "resolver should not be called" } + mod.evaluate + assert_nil mod.evaluate + end + + def test_module_accumulation_freed_by_context_dispose + # Many short-lived modules accumulate handles until ctx.dispose, which + # walks st.modules under the still-live isolate before tearing it down. + skip_on_truffleruby_module + ctx = MiniRacer::Context.new + 100.times {|i| ctx.compile_module("export const v#{i} = #{i}", filename: "m#{i}.js") } + ctx.dispose + end end