Repository navigation
Highlights
The headline is a new way to pass Ruby values into JavaScript, alongside ES modules compiled once and shared across VMs, more of the VM running off the GVL, a run of fixes to what the bridge between Ruby and JavaScript hands back, and a change to when unhandled promise rejections are reported.
Pass Ruby values into JS without interpolating them into source
VM#define_const, VM#define_let and VM#define_var expose a Ruby value to JavaScript as a real declaration. You pick the binding form, and JavaScript's own rules apply unchanged:
vm = Quickjs::VM.new
vm.define_const(:user, { name: 'Itadori', tags: ['strong', 'kind'] })
vm.eval_code("user.name + ': ' + user.tags.join(', ')") #=> "Itadori: strong, kind"| JS can reassign it | Redeclaring it in JS | On globalThis |
|
|---|---|---|---|
define_const |
no, raises Quickjs::TypeError |
Quickjs::SyntaxError |
no |
define_let |
yes | Quickjs::SyntaxError |
no |
define_var |
yes | allowed | yes |
Three methods rather than one because "global" is ambiguous in this VM: let and const declarations persist across eval_code calls but never appear on globalThis, so a single destination would diverge silently from what the JavaScript in the VM sees. define_var is the one for JavaScript you do not control, a bundle reading globalThis.APP_CONFIG.
The value is a snapshot at define time. define_function remains the way for JavaScript to read the current Ruby value on every access. Raised by @ollym in #64, which is where the shape of this was worked out (#67).
Compile an ES module once, import it into any VM
import(from: source) reparses the module in every VM it is imported into. Two new entry points parse once and hand each VM the bytecode, which is what compile already did for classic scripts.
Quickjs.compile_module returns a Quickjs::Importable you hold yourself and import wherever you like. Its module name is generated, so two libraries shipping their own JS never have to agree on one (#73):
RENDERER = Quickjs.compile_module(File.read('vendor/renderer.js'), filename: 'renderer.js')
vm = Quickjs::VM.new
vm.import(['render'], from: RENDERER) # no parse cost, on this VM or any otherQuickjs.register_module is for a module the rest of the graph imports by name. Registration is process-wide, and each VM says which ones it wants with preload_modules:. On a 220KB module, importing goes from about 10.7ms to about 1.8ms per VM. Raised by @takahashim in #65 (#66):
Quickjs.register_module('lib', source: File.read('lib.js'))
vm = Quickjs::VM.new(preload_modules: ['lib'])
vm.import(['call'], filename: 'lib')A module_loader can also return a Hash without code: to redirect a specifier to a module the VM already has, which is how a preloaded module or an Importable gets a friendly name (#74):
vm.module_loader = ->(specifier, _importer) {
{as: RENDERER.canonical_name} if specifier == 'renderer'
}One behavior change comes with the redirect. {code: modules[specifier], as: name} where the lookup misses used to raise Quickjs::TypeError at the point of the mistake. It now reads as a redirect and fails with Quickjs::ReferenceError when the target is not loaded. Return nil for an unknown specifier.
More of the VM runs off the GVL
v0.21.0 released the GVL around eval_code and registered polyfill loading. This release extends that to the rest of the compile-once, run-everywhere path. Thanks to @ursm for all three:
Runnable#runreleases the GVL under the same conditions aseval_code, so a pool of per-thread VMs running one compiled bundle scales across cores instead of running in series (#63, #69).VM#compilereleases the GVL for the parse on any VM, bridged or not, since parsing to bytecode runs no JavaScript. A shared compile VM no longer stops every other thread while it parses (#76).- Preloading a module releases the GVL while the bytecode is read into the VM, also unconditionally, so per-thread VMs preloading the same modules do so in parallel (#75).
Unhandled rejections are reported at the end of the microtask checkpoint
This changes when on_unhandled_rejection fires. Previously the tracker fired as soon as QuickJS observed a rejection, so a .catch attached later in the same tick did not suppress the notification and a chain reported once per intermediate promise. Now a rejection is reported only if it is still unhandled when the checkpoint ends, which is what the HTML specification does:
vm.eval_code("void Promise.reject(new Error('x')).catch(() => {});")
# before: reported
# now: silent, the handler was attached in the same checkpointThe upgrade note is the other half of that: the checkpoint ends when a call that runs JavaScript returns to Ruby with the job queue empty. A rejection that occurs alongside a queued job is decided at the next drain_jobs! (or a later call that drains), or at dispose!, rather than before eval_code returns. A host that inspects its collector immediately after eval_code and never drains will see nothing where it used to see the rejection; VM.new(max_pending_rejections: 0) restores immediate reporting. A pending setTimeout counts as a queued job, so FEATURE_TIMEOUT defers reports until the timers drain (#145). Thanks to @takahashim (#140, #141).
What the bridge hands back
- A revoked
Proxyis reported rather than fabricated into an emptyArray.JS_IsArrayanswers -1 when it cannot resolve a proxy, and that was read as "yes, an array", so a value JavaScript refuses to touch came back as an ordinary[]. It now raisesQuickjs::TypeError: revoked proxy, which is what the guest is told for the same value, and a log row substitutes a placeholder rather than dropping the row (#98, #139). - A host object handed to JavaScript is published under a handle a guest cannot walk to. The handle was the Ruby
object_id, a small sequential integer in a table nothing leaves, so a script could count up from 1 and be handed back aFileto read or aCryptoKeyto sign with after every JavaScript reference to it was gone. Handles are now drawn from 2^48 (#113, #117). - Every property put on an object handed to JavaScript is defined rather than assigned. An accessor on
Error.prototypeorObject.prototypeused to absorb the write, leaving no own property: a bridged error became unreportable, and aCryptoKeydescribed itself however the guest liked, including reportingextractable: truefor a key generated without it (#117). - A
CryptoKeycrossing back to JavaScript no longer carries its key material, whichinspecthad been printing into the string a pass-throughdefine_functionhanded the guest (#117). - Converting an argument for a
define_functionblock is protected, so an error from it is catchable by the guest the way every other error from that bridge already was, and a failure no longer leaks. APromiseargument leaked about 5 QuickJS objects per call before this (#143). - A
HashorArrayreturned from adefine_functionkeeps its own properties, where a guest accessor on a prototype could take the values and leave the object empty. A__proto__key becomes an own property rather than reparenting the object (#117). define_functiontells its caller what resolving a nested path ran into, rather than reporting a missing method. Thanks to @ursm (#122).- A throwing conversion owns and substitutes for the strings it cannot make. Thanks to @ursm (#110).
- A raising conversion releases what it is holding, and counts as a JS entry. Thanks to @ursm (#106).
- Converted objects are shared rather than nilling every repeat. Thanks to @ursm (#101).
Threads and limits
- "One VM, one thread at a time" is enforced. It was documented but unchecked, and since
eval_codecan release the GVL, two threads on one VM really did run inside QuickJS at once and corrupted the heap. A second thread entering a VM that is mid-call now raisesThreadError. Handing a VM from one thread to another between calls, and a bridge re-entering its own VM on the same thread, are both still fine (#86). - The stack budget follows the thread that evaluates. QuickJS latched the creating thread's stack once, so a VM built on one thread and used on another could raise
stack overflowon1 + 1, which Linux hit at the defaultmax_stack_size. The limit is now re-based on each outermost entry and clamped to what the running thread actually has, somax_stack_sizeis a ceiling rather than a guarantee (#87, #94). dispose!duringdefine_functionraisesThreadErrorrather than crashing. Resolving a nested path can run a guest getter, and adispose!reached from there freed the runtime under the call. Thanks to @ursm (#79).memory_limitaccepts values past 4GB, where it used to raise aRangeErrornamingunsigned int. A negativememory_limitormax_stack_sizeis now anArgumentErrornaming the option, wherememory_limit: -1used to become a silent 4GB cap (#94, #95).VM#compilerefuses a VM that has already run out of memory, as every other entry point did. Thanks to @ursm (#76).Quickjs::VM.allocateno longer segfaults at exit (#94).
Registered polyfills
- A registered polyfill's top level must settle synchronously. One that awaits used to be left half-applied without a word, since loading never drains the job queue. It now raises
Quickjs::NoAwaitErrorfromVM.new, and a top-level throw raises the matching error, both prefixed with the feature name. The bundled polyfills get the same check, so a load that fails under a tightmemory_limitraises rather than returning a VM withTextEncodermissing. Thanks to @ursm (#61, #68).
Known issues
timeout_mseconly fires where QuickJS polls interrupts, and building a large array or object literal barely polls, so evaluating one can run past the budget. The length is the guest's to choose (#146).
Full Changelog: v0.21.0...v0.22.0