quickjs 0.21.0 → 0.22.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,25 @@
3
3
 
4
4
  static VALUE r_find_alive_rb_file(JSContext *ctx, JSValue j_handle)
5
5
  {
6
- int64_t handle;
7
- JS_ToInt64(ctx, &handle, j_handle);
6
+ // Initialised and checked like its siblings. Unreachable today, since the
7
+ // handle is the factory closure's own number and the bridges are never on
8
+ // the global, but it feeds a table lookup and would read a garbage key.
9
+ int64_t handle = 0;
10
+ if (JS_ToInt64(ctx, &handle, j_handle) < 0)
11
+ {
12
+ quickjsrb_drain_pending(ctx);
13
+ return Qnil;
14
+ }
8
15
  VMData *data = JS_GetContextOpaque(ctx);
9
- return rb_hash_aref(data->alive_objects, LONG2NUM(handle));
16
+ VALUE r_file = rb_hash_aref(data->alive_objects, LL2NUM(handle));
17
+ // Only what this subsystem parked, the way both sibling readers check now.
18
+ // The table is shared with bridged exceptions and CryptoKeys, and the bridges
19
+ // below call File methods on whatever they are handed. Unreachable today,
20
+ // since the handle is the factory closure's own number, but the check is what
21
+ // makes that an invariant rather than an argument about reachability.
22
+ if (!rb_obj_is_kind_of(r_file, rb_cFile))
23
+ return Qnil;
24
+ return r_file;
10
25
  }
11
26
 
12
27
  static JSValue js_ruby_file_name(JSContext *ctx, JSValueConst _this, int argc, JSValueConst *argv)
@@ -99,8 +114,41 @@ static JSValue js_ruby_file_array_buffer(JSContext *ctx, JSValueConst _this, int
99
114
  return promise;
100
115
  }
101
116
 
117
+ // Hands the guest back the throw it made, rather than an error of our own,
118
+ // which would tell it less than it already knew.
119
+ //
120
+ // Deliberately without unparking: the throw is going back into JS, and it is
121
+ // the handle that lets it come out the other side as the host exception it
122
+ // started as. Taking the entry here would leave the JS error carrying a handle
123
+ // that resolves to nothing, and a caller that expected ArgumentError would get
124
+ // a generic Quickjs::RuntimeError instead. A throw the guest then catches and
125
+ // keeps stays parked, which is what any caught bridged error does: #114.
126
+ static JSValue j_rethrow_the_guests_own(JSContext *ctx)
127
+ {
128
+ // JS_Throw clears the uncatchable flag, so a deadline that fired inside the
129
+ // guest's valueOf would come back catchable and a script that caught it and
130
+ // returned would finish past its budget. Thrown the way js_poll_interrupts
131
+ // throws it instead, which is what it was before this touched it.
132
+ if (eval_budget_lapsed_now(ctx))
133
+ {
134
+ // Drained, not freed. This branch is the one that does not hand the throw
135
+ // back, so the reasoning quickjsrb_drain_pending gives for unparking
136
+ // applies here and not to the return below: nothing will carry the guest's
137
+ // throw out now, and a bridge it reached on the way has already parked the
138
+ // host exception, so freeing the JS error is the one path that leaves that
139
+ // entry with nothing able to take it.
140
+ quickjsrb_drain_pending(ctx);
141
+ JS_ThrowInternalError(ctx, "interrupted");
142
+ JS_SetUncatchableException(ctx, TRUE);
143
+ return JS_EXCEPTION;
144
+ }
145
+
146
+ return JS_Throw(ctx, JS_GetException(ctx));
147
+ }
148
+
102
149
  static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc, JSValueConst *argv)
103
150
  {
151
+ VMData *data = JS_GetContextOpaque(ctx);
104
152
  VALUE r_file = r_find_alive_rb_file(ctx, argv[0]);
105
153
  if (NIL_P(r_file))
106
154
  return JS_UNDEFINED;
@@ -111,8 +159,12 @@ static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc,
111
159
  long start = 0;
112
160
  if (argc > 1 && !JS_IsUndefined(argv[1]))
113
161
  {
114
- int64_t s;
115
- JS_ToInt64(ctx, &s, argv[1]);
162
+ // The conversion runs the guest's valueOf, which can reach a bridge: an
163
+ // unchecked failure both parks the host exception and leaves s
164
+ // uninitialized to be used as an offset.
165
+ int64_t s = 0;
166
+ if (JS_ToInt64(ctx, &s, argv[1]) < 0)
167
+ return j_rethrow_the_guests_own(ctx);
116
168
  start = (long)s;
117
169
  if (start < 0)
118
170
  start = file_size + start;
@@ -125,8 +177,9 @@ static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc,
125
177
  long end = file_size;
126
178
  if (argc > 2 && !JS_IsUndefined(argv[2]))
127
179
  {
128
- int64_t e;
129
- JS_ToInt64(ctx, &e, argv[2]);
180
+ int64_t e = 0;
181
+ if (JS_ToInt64(ctx, &e, argv[2]) < 0)
182
+ return j_rethrow_the_guests_own(ctx);
130
183
  end = (long)e;
131
184
  if (end < 0)
132
185
  end = file_size + end;
@@ -136,10 +189,6 @@ static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc,
136
189
  end = file_size;
137
190
  }
138
191
 
139
- const char *content_type = "";
140
- if (argc > 3 && JS_IsString(argv[3]))
141
- content_type = JS_ToCString(ctx, argv[3]);
142
-
143
192
  long len = end > start ? end - start : 0;
144
193
 
145
194
  rb_funcall(r_file, rb_intern("rewind"), 0);
@@ -148,30 +197,91 @@ static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc,
148
197
  VALUE r_bytes = rb_funcall(r_file, rb_intern("read"), 1, LONG2NUM(len));
149
198
  rb_funcall(r_bytes, rb_intern("force_encoding"), 1, rb_str_new_cstr("BINARY"));
150
199
 
200
+ // Built after every call that can raise, and while the C string is still
201
+ // owned, so nothing here is holding a JS value when a Ruby raise unwinds and
202
+ // nothing is left to free on the paths that bail out below.
203
+ JSValue j_type_str;
204
+ if (argc > 3 && JS_IsString(argv[3]))
205
+ {
206
+ const char *content_type = JS_ToCString(ctx, argv[3]);
207
+ if (content_type == NULL)
208
+ // It threw on the way, and returning a Blob with that left pending would
209
+ // attribute it to whatever asks next.
210
+ return j_rethrow_the_guests_own(ctx);
211
+ j_type_str = JS_NewString(ctx, content_type);
212
+ JS_FreeCString(ctx, content_type);
213
+ }
214
+ else
215
+ {
216
+ j_type_str = JS_NewString(ctx, "");
217
+ }
218
+ if (JS_IsException(j_type_str))
219
+ return j_type_str;
220
+
221
+ // Built through JS_NewTypedArray and the constructor captured at init, not
222
+ // by calling globalThis.Uint8Array and globalThis.Blob: those are names the
223
+ // guest can delete or replace with a shim, and a slice of a host File is not
224
+ // something it gets to answer for. Every step is checked, so a sentinel is
225
+ // never stored into j_parts and handed on as an ordinary element.
151
226
  JSValue j_buf = JS_NewArrayBufferCopy(ctx, (const uint8_t *)RSTRING_PTR(r_bytes), RSTRING_LEN(r_bytes));
152
- JSValue j_global = JS_GetGlobalObject(ctx);
153
- JSValue j_uint8_ctor = JS_GetPropertyStr(ctx, j_global, "Uint8Array");
154
- JSValue j_uint8 = JS_CallConstructor(ctx, j_uint8_ctor, 1, &j_buf);
227
+ if (JS_IsException(j_buf))
228
+ {
229
+ JS_FreeValue(ctx, j_type_str);
230
+ return j_buf;
231
+ }
232
+
233
+ JSValue j_ta_args[3] = {j_buf, JS_NewInt32(ctx, 0), JS_NewInt64(ctx, RSTRING_LEN(r_bytes))};
234
+ JSValue j_uint8 = JS_NewTypedArray(ctx, 3, (JSValueConst *)j_ta_args, JS_TYPED_ARRAY_UINT8);
235
+ JS_FreeValue(ctx, j_buf);
236
+ if (JS_IsException(j_uint8))
237
+ {
238
+ JS_FreeValue(ctx, j_type_str);
239
+ return j_uint8;
240
+ }
241
+
242
+ if (JS_IsUndefined(data->j_blob_ctor))
243
+ {
244
+ JS_FreeValue(ctx, j_uint8);
245
+ JS_FreeValue(ctx, j_type_str);
246
+ return JS_ThrowInternalError(ctx, "Blob is not available to build a slice");
247
+ }
155
248
 
156
249
  JSValue j_parts = JS_NewArray(ctx);
157
- JS_SetPropertyUint32(ctx, j_parts, 0, j_uint8);
250
+ if (JS_IsException(j_parts))
251
+ {
252
+ JS_FreeValue(ctx, j_uint8);
253
+ JS_FreeValue(ctx, j_type_str);
254
+ return j_parts;
255
+ }
256
+ if (JS_DefinePropertyValueUint32(ctx, j_parts, 0, j_uint8, JS_PROP_C_W_E) < 0)
257
+ {
258
+ // The define freed the bytes on its way out, so carrying on would build the
259
+ // Blob from an empty array and hand the guest a zero-length slice of a file
260
+ // that is not empty, with a throw left pending behind it.
261
+ JS_FreeValue(ctx, j_parts);
262
+ JS_FreeValue(ctx, j_type_str);
263
+ return JS_EXCEPTION;
264
+ }
158
265
 
159
266
  JSValue j_opts = JS_NewObject(ctx);
160
- JS_SetPropertyStr(ctx, j_opts, "type", JS_NewString(ctx, content_type));
267
+ if (JS_IsException(j_opts))
268
+ {
269
+ JS_FreeValue(ctx, j_parts);
270
+ JS_FreeValue(ctx, j_type_str);
271
+ return j_opts;
272
+ }
273
+ if (JS_DefinePropertyValueStr(ctx, j_opts, "type", j_type_str, JS_PROP_C_W_E) < 0)
274
+ {
275
+ JS_FreeValue(ctx, j_parts);
276
+ JS_FreeValue(ctx, j_opts);
277
+ return JS_EXCEPTION;
278
+ }
161
279
 
162
- JSValue j_blob_ctor = JS_GetPropertyStr(ctx, j_global, "Blob");
163
280
  JSValueConst blob_args[2] = {j_parts, j_opts};
164
- JSValue j_blob = JS_CallConstructor(ctx, j_blob_ctor, 2, blob_args);
281
+ JSValue j_blob = JS_CallConstructor(ctx, data->j_blob_ctor, 2, blob_args);
165
282
 
166
- if (argc > 3 && JS_IsString(argv[3]))
167
- JS_FreeCString(ctx, content_type);
168
-
169
- JS_FreeValue(ctx, j_buf);
170
- JS_FreeValue(ctx, j_uint8_ctor);
171
283
  JS_FreeValue(ctx, j_parts);
172
284
  JS_FreeValue(ctx, j_opts);
173
- JS_FreeValue(ctx, j_blob_ctor);
174
- JS_FreeValue(ctx, j_global);
175
285
 
176
286
  return j_blob;
177
287
  }
@@ -179,13 +289,28 @@ static JSValue js_ruby_file_slice(JSContext *ctx, JSValueConst _this, int argc,
179
289
  void quickjsrb_init_file_proxy(VMData *data)
180
290
  {
181
291
  const char *factory_src =
182
- "(function(getName, getSize, getType, getLastModified, getText, getArrayBuffer, getSlice) {\n"
292
+ // Everything the closure needs is bound once, here, while this is the only
293
+ // code that has run: Object, Proxy, Reflect and Symbol are the guest's to
294
+ // replace by the time a File actually crosses, and this body runs per
295
+ // crossing. With `globalThis.Proxy = null` every crossing used to fail,
296
+ // and a replaced Object.defineProperty published the handle as an
297
+ // ordinary enumerable property.
298
+ //
299
+ // The handler object has a null prototype for a sharper reason: QuickJS
300
+ // resolves a missing trap with an ordinary property get, which walks the
301
+ // handler's prototype chain. With Object.prototype in it, a guest writing
302
+ // Object.prototype.getOwnPropertyDescriptor supplies the trap for the
303
+ // one read this branch made load-bearing, and the invariant check on the
304
+ // way back compares descriptor flags without comparing the value, so a
305
+ // handle of the guest's choosing comes back for a host File.
306
+ "(function(FileProto, ObjectCreate, DefineProperty, ProxyCtor, ReflectGet, ToStringTag,\n"
307
+ " getName, getSize, getType, getLastModified, getText, getArrayBuffer, getSlice) {\n"
183
308
  " return function(handle) {\n"
184
- " var target = Object.create(File.prototype);\n"
185
- " Object.defineProperty(target, 'rb_object_id', { value: handle, enumerable: false });\n"
186
- " return new Proxy(target, {\n"
187
- " getPrototypeOf: function() { return File.prototype; },\n"
188
- " get: function(target, prop, receiver) {\n"
309
+ " var target = ObjectCreate(FileProto);\n"
310
+ " DefineProperty(target, 'rb_object_id', { value: handle, enumerable: false });\n"
311
+ " var handler = ObjectCreate(null);\n"
312
+ " handler.getPrototypeOf = function() { return FileProto; };\n"
313
+ " handler.get = function(target, prop, receiver) {\n"
189
314
  " if (prop === 'name') return getName(handle);\n"
190
315
  " if (prop === 'size') return getSize(handle);\n"
191
316
  " if (prop === 'type') return getType(handle);\n"
@@ -193,39 +318,112 @@ void quickjsrb_init_file_proxy(VMData *data)
193
318
  " if (prop === 'text') return function() { return getText(handle); };\n"
194
319
  " if (prop === 'arrayBuffer') return function() { return getArrayBuffer(handle); };\n"
195
320
  " if (prop === 'slice') return function(start, end, contentType) { return getSlice(handle, start, end, contentType); };\n"
196
- " if (prop === Symbol.toStringTag) return 'File';\n"
321
+ " if (prop === ToStringTag) return 'File';\n"
197
322
  " if (prop === 'toString') return function() { return '[object File]'; };\n"
198
- " return Reflect.get(target, prop, receiver);\n"
199
- " }\n"
200
- " });\n"
323
+ " return ReflectGet(target, prop, receiver);\n"
324
+ " };\n"
325
+ " return new ProxyCtor(target, handler);\n"
201
326
  " };\n"
202
327
  "})";
203
328
  JSValue j_factory_fn = JS_Eval(data->context, factory_src, strlen(factory_src), "<file-proxy>", JS_EVAL_TYPE_GLOBAL);
204
329
 
205
- JSValue j_helpers[7];
206
- j_helpers[0] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_name, "__rb_file_name", 1);
207
- j_helpers[1] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_size, "__rb_file_size", 1);
208
- j_helpers[2] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_type, "__rb_file_type", 1);
209
- j_helpers[3] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_last_modified, "__rb_file_last_modified", 1);
210
- j_helpers[4] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_text, "__rb_file_text", 1);
211
- j_helpers[5] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_array_buffer, "__rb_file_array_buffer", 1);
212
- j_helpers[6] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_slice, "__rb_file_slice", 4);
330
+ // The prototype is taken here too, not read from globalThis.File when a proxy
331
+ // is built: the closure below runs per crossing, which is after guest code,
332
+ // and a replaced File otherwise decides what a host File is an instance of.
333
+ // The handle keeps working either way, so what this closes is the shape of
334
+ // the value rather than the bridge.
335
+ JSValue j_global_for_proto = JS_GetGlobalObject(data->context);
336
+ JSValue j_file_class = JS_GetPropertyStr(data->context, j_global_for_proto, "File");
337
+ JS_FreeValue(data->context, j_global_for_proto);
338
+ JSValue j_file_proto = JS_GetPropertyStr(data->context, j_file_class, "prototype");
339
+ JS_FreeValue(data->context, j_file_class);
340
+ if (JS_IsException(j_file_proto))
341
+ {
342
+ // Checked like the Blob capture below. Left as the sentinel it would go
343
+ // into the factory call, come back out as j_file_proxy_creator, and every
344
+ // File crossing would call into it with a throw left pending for whatever
345
+ // asked next.
346
+ JS_FreeValue(data->context, JS_GetException(data->context));
347
+ j_file_proto = JS_UNDEFINED;
348
+ }
349
+
350
+ // The intrinsics the closure uses, taken now rather than resolved per
351
+ // crossing. Evaluated rather than read off the global one by one, so a value
352
+ // that is already missing cannot leave a sentinel in the argument list.
353
+ static const char intrinsics_src[] =
354
+ "[Object.create, Object.defineProperty, Proxy, Reflect.get, Symbol.toStringTag]";
355
+ JSValue j_intrinsics = JS_Eval(data->context, intrinsics_src, sizeof(intrinsics_src) - 1,
356
+ "<file-proxy-intrinsics>", JS_EVAL_TYPE_GLOBAL);
357
+ if (JS_IsException(j_intrinsics))
358
+ {
359
+ JS_FreeValue(data->context, JS_GetException(data->context));
360
+ JS_FreeValue(data->context, j_intrinsics);
361
+ JS_FreeValue(data->context, j_file_proto);
362
+ JS_FreeValue(data->context, j_factory_fn);
363
+ return;
364
+ }
213
365
 
214
- data->j_file_proxy_creator = JS_Call(data->context, j_factory_fn, JS_UNDEFINED, 7, j_helpers);
366
+ JSValue j_helpers[13];
367
+ j_helpers[0] = j_file_proto;
368
+ for (int i = 0; i < 5; i++)
369
+ j_helpers[1 + i] = JS_GetPropertyUint32(data->context, j_intrinsics, (uint32_t)i);
370
+ JS_FreeValue(data->context, j_intrinsics);
371
+ j_helpers[6] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_name, "__rb_file_name", 1);
372
+ j_helpers[7] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_size, "__rb_file_size", 1);
373
+ j_helpers[8] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_type, "__rb_file_type", 1);
374
+ j_helpers[9] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_last_modified, "__rb_file_last_modified", 1);
375
+ j_helpers[10] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_text, "__rb_file_text", 1);
376
+ j_helpers[11] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_array_buffer, "__rb_file_array_buffer", 1);
377
+ j_helpers[12] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_slice, "__rb_file_slice", 4);
378
+
379
+ // Freed before it is replaced, for the reason the Blob capture below gives:
380
+ // initialize is private but reachable through send, and a second pass would
381
+ // otherwise leak this closure and the seven bridges it holds.
382
+ if (!JS_IsUndefined(data->j_file_proxy_creator))
383
+ JS_FreeValue(data->context, data->j_file_proxy_creator);
384
+ data->j_file_proxy_creator = JS_Call(data->context, j_factory_fn, JS_UNDEFINED, 13, j_helpers);
385
+
386
+ // Taken here, before a line of guest code has run, because slice builds its
387
+ // result with it and globalThis.Blob is the guest's to replace by then.
388
+ //
389
+ // Once per VM, like the Proxy probe: initialize is private but reachable
390
+ // through send, and a second pass would overwrite this reference without
391
+ // freeing the first, which outlives JS_FreeRuntime along with everything it
392
+ // holds.
393
+ if (!JS_IsUndefined(data->j_blob_ctor))
394
+ JS_FreeValue(data->context, data->j_blob_ctor);
395
+ JSValue j_global = JS_GetGlobalObject(data->context);
396
+ data->j_blob_ctor = JS_GetPropertyStr(data->context, j_global, "Blob");
397
+ JS_FreeValue(data->context, j_global);
398
+ if (JS_IsException(data->j_blob_ctor))
399
+ {
400
+ JS_FreeValue(data->context, JS_GetException(data->context));
401
+ data->j_blob_ctor = JS_UNDEFINED;
402
+ }
215
403
 
216
404
  JS_FreeValue(data->context, j_factory_fn);
217
- for (int i = 0; i < 7; i++)
405
+ for (int i = 0; i < 13; i++)
218
406
  JS_FreeValue(data->context, j_helpers[i]);
219
407
  }
220
408
 
221
409
  JSValue quickjsrb_file_to_js(JSContext *ctx, VALUE r_file)
222
410
  {
223
411
  VMData *data = JS_GetContextOpaque(ctx);
224
- VALUE r_object_id = rb_funcall(r_file, rb_intern("object_id"), 0);
225
- rb_hash_aset(data->alive_objects, r_object_id, r_file);
226
- JSValue j_handle = JS_NewInt64(ctx, NUM2LONG(r_object_id));
412
+ // Taken in the same raise-safe stretch as the draw, so the rollback below
413
+ // does not dispatch from inside a JSCFunction on its way out.
414
+ VALUE r_file_object_id = rb_obj_id(r_file);
415
+ bool registered_here = false;
416
+ VALUE r_object_id = alive_objects_register(data, r_file, &registered_here);
417
+ if (NIL_P(r_object_id))
418
+ return JS_ThrowInternalError(ctx, "quickjs: could not publish a handle for a host File");
419
+ JSValue j_handle = JS_NewInt64(ctx, NUM2LL(r_object_id));
227
420
  JSValue j_proxy = JS_Call(ctx, data->j_file_proxy_creator, JS_UNDEFINED, 1, &j_handle);
228
421
  JS_FreeValue(ctx, j_handle);
422
+ if (JS_IsException(j_proxy) && registered_here)
423
+ // The third writer, taught the rollback the other two already had: the
424
+ // creator can fail, and a row left behind anchors this File and its
425
+ // descriptor for the life of the VM with nothing in JS able to reach it.
426
+ alive_objects_unregister(data, r_object_id, r_file_object_id);
229
427
  return j_proxy;
230
428
  }
231
429
 
@@ -281,7 +479,12 @@ VALUE quickjsrb_try_convert_js_file(JSContext *ctx, JSValue j_val)
281
479
  JSValue j_file_ctor = JS_GetPropertyStr(ctx, j_global, "File");
282
480
  if (!JS_IsUndefined(j_file_ctor) && !JS_IsException(j_file_ctor))
283
481
  {
482
+ // instanceof runs Symbol.hasInstance, which is the guest's to define and
483
+ // can reach a bridge: a failure here leaves that throw pending and the host
484
+ // exception it parked with nothing able to take it back out.
284
485
  int is_file = JS_IsInstanceOf(ctx, j_val, j_file_ctor);
486
+ if (is_file < 0)
487
+ quickjsrb_drain_pending(ctx);
285
488
  JS_FreeValue(ctx, j_file_ctor);
286
489
  if (is_file > 0)
287
490
  {
@@ -320,6 +523,8 @@ VALUE quickjsrb_try_convert_js_file(JSContext *ctx, JSValue j_val)
320
523
  }
321
524
 
322
525
  int is_blob = JS_IsInstanceOf(ctx, j_val, j_blob_ctor);
526
+ if (is_blob < 0)
527
+ quickjsrb_drain_pending(ctx);
323
528
  JS_FreeValue(ctx, j_blob_ctor);
324
529
  if (is_blob <= 0)
325
530
  return Qnil;
@@ -11,5 +11,18 @@ module Quickjs
11
11
  @usages = usages
12
12
  @key_data = key_data
13
13
  end
14
+
15
+ # The bytes stay out of the string form. to_js_value has no case for this
16
+ # class, so anything that hands a key back to JS, a pass-through
17
+ # define_function or a logged value, converts it by calling inspect: a key
18
+ # generated with extractable: false would otherwise reach the guest in full
19
+ # through a function that exportKey refuses one call away.
20
+ def inspect
21
+ format(
22
+ '#<%s type=%p extractable=%p algorithm=%p usages=%p key_data=[FILTERED]>',
23
+ self.class, @type, @extractable, @algorithm, @usages
24
+ )
25
+ end
26
+ alias to_s inspect
14
27
  end
15
28
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quickjs
4
+ # An ES module compiled to bytecode once and importable into any number of
5
+ # VMs without reparsing. The module-map counterpart of `Runnable`, which does
6
+ # the same for classic scripts.
7
+ #
8
+ # Unlike `Quickjs.register_module`, nothing about this is process-global:
9
+ # hold the object, drop it when you're done. That matters for a library that
10
+ # ships JS of its own, since a global registry would make unrelated gems
11
+ # coordinate on a name none of their VMs ever share.
12
+ class Importable
13
+ # The name QuickJS keys its module map by, the same thing a module_loader
14
+ # calls `as:`. Not the `filename:` that was passed in: that is only a
15
+ # prefix of this. Useful for pointing a loader at this module by a
16
+ # friendlier specifier.
17
+ attr_reader :canonical_name
18
+
19
+ def initialize(bytecode, canonical_name)
20
+ @bytecode = bytecode
21
+ @canonical_name = canonical_name
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class} #{@canonical_name} (#{@bytecode.bytesize} bytes)>"
26
+ end
27
+
28
+ private
29
+
30
+ # Deliberately not public. Handing out the blob would invite persisting it,
31
+ # and the bytecode format is tied to the QuickJS build with only a one-byte
32
+ # version tag to catch a mismatch.
33
+ def bytecode
34
+ @bytecode
35
+ end
36
+ end
37
+
38
+ # Compiles `source` as an ES module and returns an `Importable`.
39
+ #
40
+ # The module's name is generated rather than taken from the caller, because
41
+ # it is baked into the bytecode and becomes the module's identity in every VM
42
+ # that imports it. Generating it means two independently built Importables
43
+ # can never collide, which is the whole point when the JS ships inside a gem.
44
+ # `filename:` prefixes the generated name so stack traces stay readable; it is
45
+ # a label, not an identity.
46
+ #
47
+ # Compiles on a disposable VM: compilation installs a stub module loader to
48
+ # satisfy QuickJS's compile-time import resolution, and those stubs land in
49
+ # the compiling context's module map. The default timeout is generous because
50
+ # a VM's `timeout_msec` is a budget for its own JS, not for parsing.
51
+ def self.compile_module(source, filename: nil, **opts)
52
+ raise ::TypeError, "source must be a String, got #{source.class}" unless source.is_a?(String)
53
+ unless filename.nil? || filename.is_a?(String)
54
+ raise ::TypeError, "filename: must be a String, got #{filename.class}"
55
+ end
56
+
57
+ name = "#{filename || 'module'}-#{SecureRandom.hex(8)}"
58
+ vm = VM.new(**{timeout_msec: 60_000, features: []}.merge(opts))
59
+ Importable.new(vm.send(:_compile_module_to_bytecode, source, name), name)
60
+ ensure
61
+ vm&.dispose!
62
+ end
63
+
64
+ module ImportableSupport
65
+ # `from:` an Importable reads the bytecode into this VM and then imports it
66
+ # by name, so it takes the same path a preloaded module does: resolution
67
+ # finds it in the module map and `module_loader` is never asked about it.
68
+ # Reading is idempotent, so importing the same Importable repeatedly costs
69
+ # nothing after the first.
70
+ def import(imported, **opts)
71
+ importable = opts[:from]
72
+ return super unless importable.is_a?(Importable)
73
+
74
+ send(:_preload_module_bytecode, importable.send(:bytecode), importable.canonical_name)
75
+ super(imported, **opts.except(:from), filename: importable.canonical_name)
76
+ end
77
+ end
78
+
79
+ VM.prepend(ImportableSupport) unless VM.ancestors.include?(ImportableSupport)
80
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quickjs
4
+ # Process-wide registry of ES modules; entries are lazily compiled to
5
+ # bytecode on first use and the bytecode is reused across VMs.
6
+ @_modules = {}
7
+
8
+ # `name` is the module's canonical name: the string JS code writes in its
9
+ # `import` statement, and the name QuickJS keys its module map by. It is
10
+ # baked into the compiled bytecode, so registering under it is what keeps
11
+ # the registry key and the module's identity from drifting apart.
12
+ #
13
+ # `source:` accepts either a `String` (eager) or a `Proc` returning one
14
+ # (lazy). The lazy form lets a gem register a module at require time
15
+ # without paying the file-read cost unless a VM actually preloads it.
16
+ #
17
+ # Registering only makes the module available; a VM picks it up with
18
+ # `VM.new(preload_modules: [name])`. Reading a module into a VM costs
19
+ # real time whether or not it ends up imported, so preloading is a
20
+ # per-VM decision rather than something registration forces on every VM.
21
+ def self.register_module(name, source:)
22
+ raise ::TypeError, "name must be a String, got #{name.class}" unless name.is_a?(String)
23
+ raise ::TypeError, "source: must be a String or Proc, got #{source.class}" unless source.is_a?(String) || source.is_a?(Proc)
24
+
25
+ @_modules[-name] = {source: source, bytecode: nil, mutex: Mutex.new}
26
+ nil
27
+ end
28
+
29
+ def self._module_registered?(name)
30
+ @_modules.key?(name)
31
+ end
32
+
33
+ def self._unregister_module(name)
34
+ @_modules.delete(name)
35
+ nil
36
+ end
37
+
38
+ def self._preload_modules(vm, names)
39
+ names.each do |name|
40
+ raise ::TypeError, "preload_modules: entries must be Strings, got #{name.class}" unless name.is_a?(String)
41
+
42
+ entry = @_modules[name]
43
+ unless entry
44
+ raise ::ArgumentError,
45
+ "#{name.inspect} is not a registered module; call Quickjs.register_module(#{name.inspect}, source: ...) first"
46
+ end
47
+
48
+ # Same locking as registered polyfills: `||=` alone isn't atomic
49
+ # because compilation yields the GVL, so threads racing to construct
50
+ # VMs could double-compile, or a loser could read entry[:source]
51
+ # after the winner cleared it. A compile failure leaves bytecode nil
52
+ # and source intact so a later VM can retry. The unlocked first read
53
+ # keeps the hot path off the lock once the bytecode exists.
54
+ bytecode = entry[:bytecode] || entry[:mutex].synchronize {
55
+ entry[:bytecode] ||= begin
56
+ compiled = _compile_registered_module(entry, name)
57
+ entry[:source] = nil # let the Proc's captured scope be GC'd
58
+ compiled
59
+ end
60
+ }
61
+ vm.send(:_preload_module_bytecode, bytecode, name)
62
+ end
63
+ end
64
+
65
+ # Compiled on a disposable VM: compilation installs a stub module loader
66
+ # to satisfy QuickJS's compile-time import resolution, and those stubs
67
+ # land in the compiling context's module map, so it must not be a VM
68
+ # anyone goes on to use. The generous timeout covers parsing large
69
+ # bundles, since the user's per-VM `timeout_msec` is a budget for their
70
+ # own JS. `features: []` keeps registered polyfills off the temp VM.
71
+ def self._compile_registered_module(entry, name)
72
+ source = entry[:source]
73
+ source = source.call if source.is_a?(Proc)
74
+ unless source.is_a?(String)
75
+ raise ::TypeError, "source: Proc for #{name.inspect} must return a String, got #{source.class}"
76
+ end
77
+
78
+ vm = VM.new(timeout_msec: 60_000, features: [])
79
+ vm.send(:_compile_module_to_bytecode, source, name)
80
+ ensure
81
+ vm&.dispose!
82
+ end
83
+
84
+ module ModulePreloader
85
+ def initialize(preload_modules: [], **opts)
86
+ super(**opts)
87
+ unless preload_modules.is_a?(Array)
88
+ raise ::TypeError, "preload_modules: must be an Array, got #{preload_modules.class}"
89
+ end
90
+
91
+ Quickjs._preload_modules(self, preload_modules)
92
+ end
93
+ end
94
+
95
+ # Prepended after PolyfillLoader so polyfills are installed first: a
96
+ # module body can reach for a polyfilled global when it evaluates.
97
+ VM.prepend(ModulePreloader) unless VM.ancestors.include?(ModulePreloader)
98
+ end
@@ -12,6 +12,12 @@ module Quickjs
12
12
  # VM with the same feature: compilation is locked per entry, so that
13
13
  # recursion raises ThreadError instead of deadlocking silently.
14
14
  #
15
+ # The polyfill's top level must settle synchronously — no top-level
16
+ # await. `VM.new(features:)` promises a usable polyfill on return, but
17
+ # loads never drain the job queue, so nothing past the first await
18
+ # would have run by then; a pending load raises Quickjs::NoAwaitError
19
+ # instead of handing back a silently half-applied VM.
20
+ #
15
21
  # Re-registering a name replaces the entry wholesale, lock included: a
16
22
  # VM construction already compiling under the old entry finishes
17
23
  # against it (and loads the old bytecode) while the first construction
@@ -57,7 +63,15 @@ module Quickjs
57
63
  compiled
58
64
  end
59
65
  }
60
- vm.send(:_load_polyfill_bytecode, bytecode)
66
+ begin
67
+ vm.send(:_load_polyfill_bytecode, bytecode)
68
+ rescue Quickjs::RuntimeError => e
69
+ # The C layer only sees bytecode; name the registration so a VM
70
+ # with several polyfill features points at the one that failed.
71
+ # `raise e, msg` clones — class, js_name, and any JS backtrace
72
+ # already set on the original all survive.
73
+ raise e, "#{feature}: #{e.message}"
74
+ end
61
75
  end
62
76
  end
63
77