quickjs 0.21.0.rc1 → 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.
@@ -26,33 +26,119 @@ const int num_native_errors = sizeof(native_errors) / sizeof(native_errors[0]);
26
26
 
27
27
  static int dispatch_log(VMData *data, const char *severity, VALUE r_row);
28
28
  static void check_disposed(VMData *data);
29
+ static void check_js_entry_owner(VMData *data);
29
30
  static void run_gvl_release_region(VMData *data, void *(*job_run)(void *), void *job, JSValue *j_result, void *owned_buf0, void *owned_buf1);
31
+ // Defined below, next to the stack-bounds query it depends on; enter_js_entry
32
+ // above needs it.
33
+ static void rebase_stack_limit(VMData *data);
30
34
 
31
35
  JSValue to_js_value(JSContext *ctx, VALUE r_value);
32
36
  VALUE to_rb_value(JSContext *ctx, JSValue j_val);
33
- static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited);
37
+ VALUE to_rb_value_substituting(JSContext *ctx, JSValue j_val);
38
+ typedef struct ConvState ConvState;
39
+ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, ConvState *conv);
40
+ static VALUE raise_js_exception(JSContext *ctx);
34
41
  static VALUE vm_m_memoryUsage(VALUE r_self);
35
42
  static VALUE vm_m_runGC(VALUE r_self);
43
+ static VALUE vm_m_poisoned(VALUE r_self);
36
44
  static VALUE vm_m_memoryPoisoned(VALUE r_self);
37
45
  static VALUE vm_m_dispose(VALUE r_self);
38
46
  static VALUE vm_m_disposed(VALUE r_self);
39
47
  static VALUE vm_m_drainJobs(VALUE r_self);
40
48
 
41
- JSValue j_error_from_ruby_error(JSContext *ctx, VALUE r_error)
49
+ VALUE quickjsrb_secure_random = Qnil;
50
+ VALUE quickjsrb_handle_limit = Qnil;
51
+
52
+ void quickjsrb_init_handle_source(void)
42
53
  {
43
- JSValue j_error = JS_NewError(ctx); // may wanna have custom error class to determine in JS' end
54
+ quickjsrb_secure_random = rb_const_get(rb_cObject, rb_intern("SecureRandom"));
55
+ rb_gc_register_address(&quickjsrb_secure_random);
56
+
57
+ // Built without allocating, and so without a window where the global holds a
58
+ // value nothing roots yet: 2 ** 48 is a Fixnum here but a Bignum where a long
59
+ // is 32 bits, and the second call could have collected the first.
60
+ quickjsrb_handle_limit = LL2NUM(((int64_t)1 << QUICKJSRB_HANDLE_BITS) - 1);
61
+ rb_gc_register_address(&quickjsrb_handle_limit);
62
+ }
44
63
 
45
- VALUE r_object_id = rb_funcall(r_error, rb_intern("object_id"), 0);
46
- int objectId = NUM2INT(r_object_id);
47
- JS_SetPropertyStr(ctx, j_error, "rb_object_id", JS_NewInt32(ctx, objectId));
64
+ JSValue j_error_from_ruby_error(JSContext *ctx, VALUE r_error)
65
+ {
66
+ // Everything that can raise happens before anything is registered. Asking
67
+ // the exception for its message runs Ruby the host wrote, and a String with
68
+ // a null byte makes StringValueCStr raise; either longjmps out of here, and
69
+ // an entry made first would be parked in both tables for the life of the VM
70
+ // with nothing left pointing at it.
71
+ VALUE r_exception_message = rb_funcall(r_error, rb_intern("message"), 0);
72
+ const char *errorMessage = StringValueCStr(r_exception_message);
48
73
 
49
- // Keep the error alive in VMData to prevent GC before find_ruby_error retrieves it
74
+ // The draw is here with them, not below: it calls SecureRandom and writes two
75
+ // hashes, any of which can raise, and a raise after the allocations would
76
+ // longjmp out of a JSCFunction leaving them, and the promise capability of
77
+ // whichever caller is mid-flight, unreleased.
50
78
  VMData *data = JS_GetContextOpaque(ctx);
51
- rb_hash_aset(data->alive_objects, r_object_id, r_error);
79
+ // Taken before the draw, so the rollback below does not dispatch from inside
80
+ // a JSCFunction on its way out.
81
+ VALUE r_error_object_id = rb_obj_id(r_error);
82
+ bool registered_here = false;
83
+ VALUE r_object_id = alive_objects_register(data, r_error, &registered_here);
84
+ if (NIL_P(r_object_id))
85
+ // No handle means no way to hand this exception back out, so the guest gets
86
+ // an error of its own rather than one that looks bridged and is not.
87
+ return JS_ThrowInternalError(ctx, "quickjs: could not publish a handle for a host exception");
52
88
 
53
- VALUE r_exception_message = rb_funcall(r_error, rb_intern("message"), 0);
54
- const char *errorMessage = StringValueCStr(r_exception_message);
55
- JS_SetPropertyStr(ctx, j_error, "message", JS_NewString(ctx, errorMessage));
89
+ JSValue j_error = JS_NewError(ctx); // may wanna have custom error class to determine in JS' end
90
+ JSValue j_message = JS_NewString(ctx, errorMessage);
91
+ // Both are allocations, and either answers JS_EXCEPTION on a heap that has
92
+ // run out. Storing the sentinel would put it on the error as an ordinary
93
+ // value and mask the throw that produced it, so neither is published and the
94
+ // caller is told instead.
95
+ if (JS_IsException(j_error) || JS_IsException(j_message))
96
+ {
97
+ if (registered_here)
98
+ alive_objects_unregister(data, r_object_id, r_error_object_id);
99
+ JS_FreeValue(ctx, j_error);
100
+ JS_FreeValue(ctx, j_message);
101
+ return JS_EXCEPTION;
102
+ }
103
+
104
+ // Defined, not set: JS_SetPropertyStr walks the prototype chain, so an
105
+ // accessor a guest installs on Error.prototype absorbs the write and no own
106
+ // property is ever created, while the reader insists on an own data one.
107
+ // Every host exception would then be both unreportable and permanently
108
+ // parked, on one line of guest setup. Non-enumerable for the same reason the
109
+ // File and CryptoKey writers are: the handle is not part of the error.
110
+ //
111
+ // The message is defined for the same reason, and is non-enumerable with it,
112
+ // which is a change: assignment made it enumerable, so a bridged error was
113
+ // the one Error in the runtime whose message showed up in JSON.stringify and
114
+ // Object.keys. Errors the guest makes itself do not, because that is what the
115
+ // Error constructor does, and a bridged one is not special enough to differ.
116
+ if (JS_DefinePropertyValueStr(ctx, j_error, "rb_object_id",
117
+ JS_NewInt64(ctx, NUM2LL(r_object_id)),
118
+ JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE) < 0)
119
+ {
120
+ // Only the row this call made. The registrar reuses one when the same
121
+ // object is bridged twice, and tearing that down would unanchor the error
122
+ // an earlier crossing is still holding.
123
+ if (registered_here)
124
+ {
125
+ alive_objects_unregister(data, r_object_id, r_error_object_id);
126
+ }
127
+ JS_FreeValue(ctx, j_message);
128
+ JS_FreeValue(ctx, j_error);
129
+ return JS_EXCEPTION;
130
+ }
131
+
132
+ if (JS_DefinePropertyValueStr(ctx, j_error, "message", j_message,
133
+ JS_PROP_WRITABLE | JS_PROP_CONFIGURABLE) < 0)
134
+ {
135
+ if (registered_here)
136
+ {
137
+ alive_objects_unregister(data, r_object_id, r_error_object_id);
138
+ }
139
+ JS_FreeValue(ctx, j_error);
140
+ return JS_EXCEPTION;
141
+ }
56
142
 
57
143
  return j_error;
58
144
  }
@@ -61,6 +147,7 @@ typedef struct
61
147
  {
62
148
  JSContext *ctx;
63
149
  JSValue j_obj;
150
+ bool failed;
64
151
  } RbHashToJsArg;
65
152
 
66
153
  static int rb_hash_entry_to_js(VALUE r_key, VALUE r_val, VALUE extra)
@@ -80,7 +167,30 @@ static int rb_hash_entry_to_js(VALUE r_key, VALUE r_val, VALUE extra)
80
167
  VALUE r_key_str = rb_funcall(r_key, rb_intern("to_s"), 0);
81
168
  key_cstr = StringValueCStr(r_key_str);
82
169
  }
83
- JS_SetPropertyStr(arg->ctx, arg->j_obj, key_cstr, to_js_value(arg->ctx, r_val));
170
+ JSValue j_val = to_js_value(arg->ctx, r_val);
171
+ // A member that could not be built is not stored as the sentinel: doing so
172
+ // makes any later read of that slot behave as though an exception were
173
+ // pending. The walk stops and the caller is told.
174
+ if (JS_IsException(j_val))
175
+ {
176
+ arg->failed = true;
177
+ return ST_STOP;
178
+ }
179
+
180
+ // Defined, not assigned, on an object we just built and are about to hand
181
+ // over: JS_SetPropertyStr walks the prototype chain, so a setter the guest
182
+ // installed on Object.prototype under this key receives the host's value and
183
+ // the object goes back with no own property of that name at all. It also
184
+ // keeps a "__proto__" key a key, rather than letting it reparent the object.
185
+ if (JS_DefinePropertyValueStr(arg->ctx, arg->j_obj, key_cstr, j_val, JS_PROP_C_W_E) < 0)
186
+ {
187
+ // Reported rather than shrugged off: the object would otherwise go back
188
+ // looking complete, missing that one key, with the throw the define set
189
+ // left for whatever asks next. A key the guest can then answer for with a
190
+ // prototype accessor is exactly what defining it was meant to prevent.
191
+ arg->failed = true;
192
+ return ST_STOP;
193
+ }
84
194
  return ST_CONTINUE;
85
195
  }
86
196
 
@@ -96,15 +206,13 @@ JSValue to_js_value(JSContext *ctx, VALUE r_value)
96
206
  return JS_NewFloat64(ctx, NUM2DBL(r_value));
97
207
  case T_BIGNUM:
98
208
  {
209
+ // strtod on the decimal we just produced, rather than calling globalThis's
210
+ // Number: that name is the guest's to delete or replace with a shim that
211
+ // throws, and a Bignum crossing over is not something it gets to answer
212
+ // for. The value is the same one Number(str) would have produced, since
213
+ // this is the conversion Number(str) performs.
99
214
  VALUE r_str = rb_funcall(r_value, rb_intern("to_s"), 0);
100
- JSValue j_str = JS_NewStringLen(ctx, RSTRING_PTR(r_str), RSTRING_LEN(r_str));
101
- JSValue j_global = JS_GetGlobalObject(ctx);
102
- JSValue j_numberClass = JS_GetPropertyStr(ctx, j_global, "Number");
103
- JSValue j_num = JS_Call(ctx, j_numberClass, JS_UNDEFINED, 1, (JSValueConst *)&j_str);
104
- JS_FreeValue(ctx, j_str);
105
- JS_FreeValue(ctx, j_numberClass);
106
- JS_FreeValue(ctx, j_global);
107
- return j_num;
215
+ return JS_NewFloat64(ctx, rb_str_to_dbl(r_str, FALSE));
108
216
  }
109
217
  case T_STRING:
110
218
  return JS_NewStringLen(ctx, RSTRING_PTR(r_value), RSTRING_LEN(r_value));
@@ -132,15 +240,36 @@ JSValue to_js_value(JSContext *ctx, VALUE r_value)
132
240
  JSValue j_arr = JS_NewArray(ctx);
133
241
  for (int i = 0; i < len; i++)
134
242
  {
135
- JS_SetPropertyUint32(ctx, j_arr, (uint32_t)i, to_js_value(ctx, RARRAY_AREF(r_value, i)));
243
+ JSValue j_element = to_js_value(ctx, RARRAY_AREF(r_value, i));
244
+ // See rb_hash_entry_to_js: an element that could not be built is not
245
+ // stored as the sentinel.
246
+ if (JS_IsException(j_element))
247
+ {
248
+ JS_FreeValue(ctx, j_arr);
249
+ return JS_EXCEPTION;
250
+ }
251
+
252
+ // Defined for the same reason, which reaches elements too: any index
253
+ // property on Array.prototype costs the fast array path, and the write
254
+ // then walks the chain into the guest's setter.
255
+ if (JS_DefinePropertyValueUint32(ctx, j_arr, (uint32_t)i, j_element, JS_PROP_C_W_E) < 0)
256
+ {
257
+ JS_FreeValue(ctx, j_arr);
258
+ return JS_EXCEPTION;
259
+ }
136
260
  }
137
261
  return j_arr;
138
262
  }
139
263
  case T_HASH:
140
264
  {
141
265
  JSValue j_obj = JS_NewObject(ctx);
142
- RbHashToJsArg arg = {ctx, j_obj};
266
+ RbHashToJsArg arg = {ctx, j_obj, false};
143
267
  rb_hash_foreach(r_value, rb_hash_entry_to_js, (VALUE)&arg);
268
+ if (arg.failed)
269
+ {
270
+ JS_FreeValue(ctx, j_obj);
271
+ return JS_EXCEPTION;
272
+ }
144
273
  return j_obj;
145
274
  }
146
275
  default:
@@ -153,7 +282,7 @@ JSValue to_js_value(JSContext *ctx, VALUE r_value)
153
282
  }
154
283
  if (rb_obj_is_kind_of(r_value, rb_eException))
155
284
  {
156
- return j_error_from_ruby_error(ctx, r_value);
285
+ return j_error_from_ruby_error(ctx, r_value); // JS_EXCEPTION propagates to the caller
157
286
  }
158
287
  VALUE r_inspect_str = rb_funcall(r_value, rb_intern("inspect"), 0);
159
288
  char *str = StringValueCStr(r_inspect_str);
@@ -163,20 +292,180 @@ JSValue to_js_value(JSContext *ctx, VALUE r_value)
163
292
  }
164
293
  }
165
294
 
295
+ static VALUE find_ruby_error_at(JSContext *ctx, JSValue j_error, int64_t *peek_handle);
296
+
297
+ static bool js_is_proxy(JSContext *ctx, JSValue j_val)
298
+ {
299
+ VMData *data = JS_GetContextOpaque(ctx);
300
+ return JS_IsObject(j_val) && data->proxy_class_id != 0 && JS_GetClassID(j_val) == data->proxy_class_id;
301
+ }
302
+
303
+ // With peek_handle, the exception is left in alive_objects and its handle
304
+ // written there, for a caller that may take it later.
305
+ static VALUE find_ruby_error_peeking(JSContext *ctx, JSValue j_error, int64_t *peek_handle)
306
+ {
307
+ // Whether anything was already pending before we read anything. Without it,
308
+ // a throw some earlier unchecked read left set looks exactly like one our own
309
+ // read just caused, and an unrelated conversion is handed the host exception
310
+ // it carried.
311
+ // Held, not just noted: a trap that throws during the read replaces what was
312
+ // pending, and JS_Throw frees the old one, so asking "was something pending"
313
+ // afterwards cannot tell a throw we caused from one that was already there.
314
+ // Comparing the values can.
315
+ //
316
+ // Whether it was uncatchable is noted with it: JS_Throw clears that flag and
317
+ // JS_GetException leaves it alone, so putting a lapsed budget's interrupt
318
+ // back without it would hand the guest something it can catch and return
319
+ // from, which is what j_rethrow_the_guests_own guards against by hand.
320
+ JSValue j_was_pending = JS_HasException(ctx) ? JS_GetException(ctx) : JS_UNINITIALIZED;
321
+ bool was_pending = !JS_IsUninitialized(j_was_pending);
322
+ bool was_uncatchable = was_pending && eval_budget_lapsed_now(ctx);
323
+ if (was_pending)
324
+ {
325
+ JS_Throw(ctx, JS_DupValue(ctx, j_was_pending));
326
+ if (was_uncatchable)
327
+ JS_SetUncatchableException(ctx, TRUE);
328
+ }
329
+
330
+ VALUE r_error = find_ruby_error_at(ctx, j_error, peek_handle);
331
+ if (!NIL_P(r_error) || !JS_HasException(ctx))
332
+ {
333
+ JS_FreeValue(ctx, j_was_pending);
334
+ return r_error;
335
+ }
336
+
337
+ JSValue j_trap_threw = JS_GetException(ctx);
338
+ if (was_pending && JS_VALUE_GET_PTR(j_trap_threw) == JS_VALUE_GET_PTR(j_was_pending))
339
+ {
340
+ // The same throw is still sitting there, so it is not ours to take.
341
+ JS_Throw(ctx, j_trap_threw);
342
+ if (was_uncatchable)
343
+ JS_SetUncatchableException(ctx, TRUE);
344
+ JS_FreeValue(ctx, j_was_pending);
345
+ return r_error;
346
+ }
347
+
348
+ // The read was answered by a Proxy's descriptor trap, and the trap threw.
349
+ // That throw is taken rather than left for an unrelated evaluation, and asked
350
+ // once whether it carries a host exception, because a bridge reached from the
351
+ // trap parks one and only this takes it back out.
352
+ //
353
+ // Once, and not if what it threw is itself a Proxy: asking that would run
354
+ // another trap, which is a chain the guest chooses the length of. Refusing at
355
+ // this level costs a bridged error wrapped in a Proxy thrown by a trap, and
356
+ // keeps one wrapped in a Proxy thrown directly, which is the shape that
357
+ // reaches here in practice.
358
+ if (!js_is_proxy(ctx, j_trap_threw))
359
+ r_error = find_ruby_error_at(ctx, j_trap_threw, peek_handle);
360
+ JS_FreeValue(ctx, j_trap_threw);
361
+
362
+ // Put back what was pending before any of this, which the trap's throw
363
+ // displaced. Taking the trap's throw is this function's business; losing an
364
+ // unrelated one that was already travelling is not, and the guard at the top
365
+ // exists to say so.
366
+ if (was_pending)
367
+ {
368
+ JS_Throw(ctx, j_was_pending);
369
+ if (was_uncatchable)
370
+ JS_SetUncatchableException(ctx, TRUE);
371
+ }
372
+ return r_error;
373
+ }
374
+
166
375
  VALUE find_ruby_error(JSContext *ctx, JSValue j_error)
167
376
  {
168
- JSValue j_errorOriginalRubyObjectId = JS_GetPropertyStr(ctx, j_error, "rb_object_id");
169
- int errorOriginalRubyObjectId = 0;
170
- if (JS_VALUE_GET_NORM_TAG(j_errorOriginalRubyObjectId) == JS_TAG_INT)
377
+ return find_ruby_error_peeking(ctx, j_error, NULL);
378
+ }
379
+
380
+ // Takes a peeked exception out of alive_objects, unless something took it since.
381
+ static void release_peeked_ruby_error(VMData *data, int64_t handle, VALUE r_error)
382
+ {
383
+ VALUE r_key = LL2NUM(handle);
384
+ if (rb_hash_lookup2(data->alive_objects, r_key, Qundef) != r_error)
385
+ return;
386
+ VALUE r_error_object_id = rb_obj_id(r_error);
387
+ rb_hash_delete(data->alive_objects, r_key);
388
+ rb_hash_delete(data->alive_handles, r_error_object_id);
389
+ }
390
+
391
+ static VALUE find_ruby_error_at(JSContext *ctx, JSValue j_error, int64_t *peek_handle)
392
+ {
393
+ // Most callers know they hold an Error before they ask, but the one that
394
+ // inspects a failed conversion's throw cannot: `throw null` and `throw 1` are
395
+ // both legal. Reading a property off a primitive throws a TypeError that
396
+ // nothing here consumes, which would leave the runtime carrying a pending
397
+ // exception into the next evaluation — the very thing to_r_json goes out of
398
+ // its way to clear. A non-object was never a bridged Ruby error anyway.
399
+ if (!JS_IsObject(j_error))
400
+ return Qnil;
401
+
402
+ // Read as an own data property, never through a getter: all three writers
403
+ // define it that way, so nothing legitimate is missed, and a getter on a
404
+ // thrown object would otherwise reach a bridge from inside the lookup that
405
+ // exists to take a bridged exception back out.
406
+ //
407
+ // Not quite "runs no guest code", which is what this said before: a Proxy's
408
+ // getOwnPropertyDescriptor trap answers this read too, and a trap that
409
+ // throws makes JS_GetOwnProperty answer -1 with its throw left pending.
410
+ // That is a different answer from "no such property" and is kept apart from
411
+ // it, because folding the two left the trap's throw set and its parked
412
+ // exception unreachable.
413
+ JSAtom handle_atom = JS_NewAtom(ctx, "rb_object_id");
414
+ JSPropertyDescriptor desc;
415
+ int found = JS_GetOwnProperty(ctx, &desc, j_error, handle_atom);
416
+ JS_FreeAtom(ctx, handle_atom);
417
+ // A trap that threw leaves its throw pending, which find_ruby_error takes.
418
+ if (found <= 0)
419
+ return Qnil;
420
+ if ((desc.flags & JS_PROP_TMASK) != JS_PROP_NORMAL)
171
421
  {
172
- JS_ToInt32(ctx, &errorOriginalRubyObjectId, j_errorOriginalRubyObjectId);
422
+ JS_FreeValue(ctx, desc.value);
423
+ JS_FreeValue(ctx, desc.getter);
424
+ JS_FreeValue(ctx, desc.setter);
425
+ return Qnil;
426
+ }
427
+ JS_FreeValue(ctx, desc.getter);
428
+ JS_FreeValue(ctx, desc.setter);
429
+ JSValue j_errorOriginalRubyObjectId = desc.value;
430
+ int64_t errorOriginalRubyObjectId = 0;
431
+ // FLOAT64 as well as INT: the handle is drawn from a range wider than a
432
+ // tagged int, so QuickJS carries most of them as doubles.
433
+ if (JS_VALUE_GET_NORM_TAG(j_errorOriginalRubyObjectId) == JS_TAG_INT || JS_VALUE_GET_NORM_TAG(j_errorOriginalRubyObjectId) == JS_TAG_FLOAT64)
434
+ {
435
+ JS_ToInt64(ctx, &errorOriginalRubyObjectId, j_errorOriginalRubyObjectId);
173
436
  JS_FreeValue(ctx, j_errorOriginalRubyObjectId);
174
437
  if (errorOriginalRubyObjectId > 0)
175
438
  {
176
439
  VMData *data = JS_GetContextOpaque(ctx);
177
- VALUE r_key = INT2NUM(errorOriginalRubyObjectId);
440
+ VALUE r_key = LL2NUM(errorOriginalRubyObjectId);
178
441
  VALUE r_error = rb_hash_aref(data->alive_objects, r_key);
442
+ // alive_objects anchors three unrelated things: a bridged exception,
443
+ // waiting to be thrown back, and the Ruby objects behind a File or a
444
+ // CryptoKey proxy, which stay reachable for as long as the guest holds
445
+ // the proxy. Only the first is this function's to take. The id is an
446
+ // ordinary property the guest can write, so without the check a script
447
+ // that copies a live proxy's id onto anything throwable severs that
448
+ // proxy: the entry is deleted, and every property of the File it stood
449
+ // for reads back as undefined while the object still passes
450
+ // `instanceof File`. Leaving a foreign entry anchored is the same
451
+ // answer as never having matched.
452
+ if (!rb_obj_is_kind_of(r_error, rb_eException))
453
+ return Qnil;
454
+ if (peek_handle != NULL)
455
+ {
456
+ *peek_handle = errorOriginalRubyObjectId;
457
+ return r_error;
458
+ }
459
+ // Both rows, and the reverse one's key taken before either delete:
460
+ // rb_obj_id rather than the object_id method, which a subclass may
461
+ // override and which may allocate, so asking after the first delete
462
+ // could unanchor the exception and then lose it on the way to the
463
+ // second. The reverse row is only there so an object bridged twice keeps
464
+ // one handle, and once the entry is gone there is nothing for it to
465
+ // point at.
466
+ VALUE r_error_object_id = rb_obj_id(r_error);
179
467
  rb_hash_delete(data->alive_objects, r_key);
468
+ rb_hash_delete(data->alive_handles, r_error_object_id);
180
469
  return r_error;
181
470
  }
182
471
  }
@@ -187,6 +476,38 @@ VALUE find_ruby_error(JSContext *ctx, JSValue j_error)
187
476
  return Qnil;
188
477
  }
189
478
 
479
+ // Takes a throw nobody is going to report, without losing what it carries. A
480
+ // read that runs the guest's own code can reach a Ruby bridge, and the bridge
481
+ // parks the host's exception in alive_objects on the way out; find_ruby_error
482
+ // is the only thing that takes one back out, so a reader that merely frees the
483
+ // JS_EXCEPTION pins one per call, at a rate the guest picks. The exception
484
+ // itself is still lost, which is the caller's business to say.
485
+ void quickjsrb_drain_pending(JSContext *ctx)
486
+ {
487
+ // One pass here. The reader it calls can run a Proxy's descriptor trap, and
488
+ // takes what that throws itself, so a drain can run guest code and this is
489
+ // not the pure cleanup it looks like.
490
+ //
491
+ // A budget that lapsed inside one of those getters goes with the rest, and
492
+ // that is not right: the sibling drain in js_hold_cstring_or_null latches it.
493
+ // Latching here does not help on its own, though. Re-throwing the interrupt
494
+ // leaves it pending for a caller that carries on regardless, which is how the
495
+ // guest's own error is lost too. Both want the refusal path in #130, and
496
+ // both are the same on main.
497
+ //
498
+ // Unparking here and not in j_rethrow_the_guests_own is deliberate, and the
499
+ // difference is what happens to the throw. That one hands it back to the
500
+ // guest, so it can still travel out and be reported as the host exception it
501
+ // was, and taking the entry would break that. This one drops it, so nothing
502
+ // will carry it out and an anchor left behind is only a leak. What it costs
503
+ // is a guest that kept its own reference: rethrowing that later reports the
504
+ // message but a generic class, since the handle it carries no longer
505
+ // resolves.
506
+ JSValue j_pending = JS_GetException(ctx);
507
+ find_ruby_error(ctx, j_pending);
508
+ JS_FreeValue(ctx, j_pending);
509
+ }
510
+
190
511
  VALUE r_try_json_parse(VALUE r_str)
191
512
  {
192
513
  return rb_funcall(rb_const_get(rb_cClass, rb_intern("JSON")), rb_intern("parse"), 1, r_str);
@@ -203,7 +524,14 @@ static VALUE r_backtrace_from_js_stack(const char *stack)
203
524
  if (stack == NULL || stack[0] == '\0')
204
525
  return Qnil;
205
526
 
206
- VALUE r_lines = rb_str_split(rb_str_new_cstr(stack), "\n");
527
+ // Through rb_funcall and not rb_str_split: the latter enters String#split
528
+ // directly, so it honours whatever block the current C frame was called
529
+ // with. Any entry point has one if the caller passed one — eval_code with a
530
+ // stray block, define_function always — and there split yielded every line
531
+ // of the JS stack to that block and answered the String instead of an
532
+ // Array, which rb_ary_entry then dereferenced as one. rb_funcall never
533
+ // forwards a block.
534
+ VALUE r_lines = rb_funcall(rb_str_new_cstr(stack), rb_intern("split"), 1, rb_str_new_cstr("\n"));
207
535
  VALUE r_filtered = rb_ary_new();
208
536
  for (long i = 0; i < RARRAY_LEN(r_lines); i++)
209
537
  {
@@ -255,49 +583,653 @@ static int js_is_plain_object(JSContext *ctx, JSValue j_val)
255
583
  return result;
256
584
  }
257
585
 
258
- static VALUE js_array_to_rb(JSContext *ctx, JSValue j_val, VALUE r_visited)
586
+ #define CONV_PINNED_INLINE 8
587
+ #define CONV_FRAMES_INLINE 8
588
+
589
+ // One frame per object the walk has entered. The walk borrows JS references —
590
+ // the property table, and the element, property or toJSON result currently
591
+ // being converted — and holds them across a recursive call. A Ruby exception
592
+ // raised anywhere below longjmps straight past the frees, so the frame holds
593
+ // those references on the walk's behalf and conv_release hands them back.
594
+ //
595
+ // The references live in the frame rather than being pointed at, because the C
596
+ // frames that borrowed them are already unwound by the time the ensure runs.
597
+ typedef struct
259
598
  {
260
- JSValue j_length = JS_GetPropertyStr(ctx, j_val, "length");
599
+ JSPropertyEnum *ptab; // property table this frame owns, NULL when it has none
600
+ uint32_t plen;
601
+ JSValue j_borrowed; // value being converted, JS_UNDEFINED when between two
602
+ const char *key; // property name being converted, NULL when between two
603
+ } ConvFrame;
604
+
605
+ // State threaded through the conversion of one JS object graph.
606
+ //
607
+ // `r_seen` maps an object's address either to CONV_IN_PROGRESS, meaning the
608
+ // object is an ancestor of the one being converted, or to the Ruby value it
609
+ // finished converting to. The first case is a cycle and becomes nil; the second
610
+ // is a shared subgraph, which converts once and stays shared on the Ruby side.
611
+ // Only the containers this conversion builds are kept — see the toJSON branch.
612
+ //
613
+ // Every tracked object is pinned with JS_DupValue until the whole conversion
614
+ // finishes. Property getters and toJSON run guest JS, which can drop the last
615
+ // reference to an object and let a new one be allocated at the same address;
616
+ // without the pin the map would answer for an object that no longer exists.
617
+ struct ConvState
618
+ {
619
+ JSContext *ctx;
620
+ JSValue j_root;
621
+ VALUE r_seen;
622
+ JSValue *pinned;
623
+ long pinned_count;
624
+ long pinned_capacity;
625
+ JSValue pinned_inline[CONV_PINNED_INLINE];
626
+ ConvFrame *frames;
627
+ long frame_count;
628
+ long frame_capacity;
629
+ ConvFrame frames_inline[CONV_FRAMES_INLINE];
630
+ // Set when the caller cannot be handed an exception: a console.log row is
631
+ // built inside a JSCFunction, so raising from there would unwind through
632
+ // QuickJS, and it would cost the guest the rest of the statement and the
633
+ // convertible arguments beside the offending one.
634
+ int substitute_unresolvable;
635
+ };
636
+
637
+ // Frames move when the stack grows, so a walk holds its depth and re-derives
638
+ // the pointer rather than keeping one across a recursive call.
639
+ #define CONV_FRAME(conv, depth) (&(conv)->frames[depth])
640
+
641
+ // The map is private to one conversion, so using it as its own marker cannot
642
+ // collide with any value an object could convert to.
643
+ #define CONV_IN_PROGRESS(conv) ((conv)->r_seen)
644
+
645
+ static void conv_pin(ConvState *conv, JSValue j_val)
646
+ {
647
+ if (conv->pinned_count == conv->pinned_capacity)
648
+ {
649
+ long capacity = conv->pinned_capacity * 2;
650
+ JSValue *pinned = xmalloc2(capacity, sizeof(JSValue));
651
+ JSValue *previous = conv->pinned;
652
+ memcpy(pinned, previous, conv->pinned_count * sizeof(JSValue));
653
+ conv->pinned = pinned;
654
+ conv->pinned_capacity = capacity;
655
+ if (previous != conv->pinned_inline)
656
+ xfree(previous);
657
+ }
658
+ conv->pinned[conv->pinned_count++] = JS_DupValue(conv->ctx, j_val);
659
+ }
660
+
661
+ static long conv_frame_push(ConvState *conv)
662
+ {
663
+ if (conv->frame_count == conv->frame_capacity)
664
+ {
665
+ long capacity = conv->frame_capacity * 2;
666
+ ConvFrame *frames = xmalloc2(capacity, sizeof(ConvFrame));
667
+ ConvFrame *previous = conv->frames;
668
+ memcpy(frames, previous, conv->frame_count * sizeof(ConvFrame));
669
+ conv->frames = frames;
670
+ conv->frame_capacity = capacity;
671
+ if (previous != conv->frames_inline)
672
+ xfree(previous);
673
+ }
674
+ long depth = conv->frame_count++;
675
+ ConvFrame *frame = CONV_FRAME(conv, depth);
676
+ frame->ptab = NULL;
677
+ frame->plen = 0;
678
+ frame->j_borrowed = JS_UNDEFINED;
679
+ frame->key = NULL;
680
+ return depth;
681
+ }
682
+
683
+ // Hand a freshly acquired reference to the frame, which owns it until the walk
684
+ // returns it or the conversion unwinds. Returns it for the caller to use.
685
+ static JSValue conv_borrow(ConvState *conv, long depth, JSValue j_val)
686
+ {
687
+ CONV_FRAME(conv, depth)->j_borrowed = j_val;
688
+ return j_val;
689
+ }
690
+
691
+ static const char *conv_borrow_key(ConvState *conv, long depth, const char *key)
692
+ {
693
+ CONV_FRAME(conv, depth)->key = key;
694
+ return key;
695
+ }
696
+
697
+ // Release whatever the frame is currently holding, leaving it ready for the
698
+ // next iteration. Freeing JS_UNDEFINED is a no-op, so this is safe to call on a
699
+ // frame that borrowed only one of the two.
700
+ static void conv_return(ConvState *conv, long depth)
701
+ {
702
+ ConvFrame *frame = CONV_FRAME(conv, depth);
703
+ if (frame->key != NULL)
704
+ {
705
+ JS_FreeCString(conv->ctx, frame->key);
706
+ frame->key = NULL;
707
+ }
708
+ JS_FreeValue(conv->ctx, frame->j_borrowed);
709
+ frame->j_borrowed = JS_UNDEFINED;
710
+ }
711
+
712
+ static void conv_frame_pop(ConvState *conv)
713
+ {
714
+ long depth = --conv->frame_count;
715
+ conv_return(conv, depth);
716
+ ConvFrame *frame = CONV_FRAME(conv, depth);
717
+ if (frame->ptab != NULL)
718
+ {
719
+ JS_FreePropertyEnum(conv->ctx, frame->ptab, frame->plen);
720
+ frame->ptab = NULL;
721
+ }
722
+ }
723
+
724
+ // What a value that cannot be rendered is shown as, wherever one is shown
725
+ // rather than reported.
726
+ #define QUICKJSRB_UNRENDERABLE "(unrenderable value)"
727
+
728
+ static VALUE js_array_to_rb(JSContext *ctx, JSValue j_val, ConvState *conv)
729
+ {
730
+ long depth = conv_frame_push(conv);
731
+
732
+ JSValue j_length = conv_borrow(conv, depth, JS_GetPropertyStr(ctx, j_val, "length"));
261
733
  uint32_t length = 0;
262
734
  JS_ToUint32(ctx, &length, j_length);
263
- JS_FreeValue(ctx, j_length);
735
+ conv_return(conv, depth);
264
736
 
265
737
  VALUE r_array = rb_ary_new_capa(length);
266
738
  for (uint32_t i = 0; i < length; i++)
267
739
  {
268
- JSValue j_elem = JS_GetPropertyUint32(ctx, j_val, i);
269
- rb_ary_push(r_array, to_rb_value_inner(ctx, j_elem, r_visited));
270
- JS_FreeValue(ctx, j_elem);
740
+ JSValue j_elem = conv_borrow(conv, depth, JS_GetPropertyUint32(ctx, j_val, i));
741
+ rb_ary_push(r_array, to_rb_value_inner(ctx, j_elem, conv));
742
+ conv_return(conv, depth);
271
743
  }
744
+
745
+ conv_frame_pop(conv);
272
746
  return r_array;
273
747
  }
274
748
 
275
- static VALUE js_plain_object_to_rb(JSContext *ctx, JSValue j_val, VALUE r_visited)
749
+ static VALUE js_plain_object_to_rb(JSContext *ctx, JSValue j_val, ConvState *conv)
276
750
  {
751
+ // The frame is pushed before the table exists so that nothing can raise
752
+ // between acquiring it and handing it over.
753
+ long depth = conv_frame_push(conv);
754
+
277
755
  JSPropertyEnum *ptab;
278
756
  uint32_t plen;
279
757
  if (JS_GetOwnPropertyNames(ctx, &ptab, &plen, j_val, JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) < 0)
758
+ {
759
+ conv_frame_pop(conv);
280
760
  return rb_hash_new();
761
+ }
762
+ CONV_FRAME(conv, depth)->ptab = ptab;
763
+ CONV_FRAME(conv, depth)->plen = plen;
281
764
 
282
765
  VALUE r_hash = rb_hash_new();
283
766
  for (uint32_t i = 0; i < plen; i++)
284
767
  {
285
- const char *key = JS_AtomToCString(ctx, ptab[i].atom);
286
- JSValue j_prop = JS_GetProperty(ctx, j_val, ptab[i].atom);
287
- rb_hash_aset(r_hash, rb_str_new2(key), to_rb_value_inner(ctx, j_prop, r_visited));
288
- JS_FreeCString(ctx, key);
289
- JS_FreeValue(ctx, j_prop);
768
+ const char *key = conv_borrow_key(conv, depth, JS_AtomToCString(ctx, ptab[i].atom));
769
+ // An atom is already a string, so nothing guest-written runs here and only
770
+ // a failed allocation answers NULL. Reporting that as the out-of-memory it
771
+ // is beats handing the NULL to rb_str_new2 two lines down.
772
+ if (key == NULL)
773
+ return raise_js_exception(ctx);
774
+ JSValue j_prop = conv_borrow(conv, depth, JS_GetProperty(ctx, j_val, ptab[i].atom));
775
+ rb_hash_aset(r_hash, rb_str_new2(key), to_rb_value_inner(ctx, j_prop, conv));
776
+ conv_return(conv, depth);
290
777
  }
291
- JS_FreePropertyEnum(ctx, ptab, plen);
778
+
779
+ conv_frame_pop(conv);
292
780
  return r_hash;
293
781
  }
294
782
 
783
+ static VALUE conv_run(VALUE r_conv)
784
+ {
785
+ ConvState *conv = (ConvState *)r_conv;
786
+ return to_rb_value_inner(conv->ctx, conv->j_root, conv);
787
+ }
788
+
789
+ static VALUE conv_release(VALUE r_conv)
790
+ {
791
+ ConvState *conv = (ConvState *)r_conv;
792
+ // Innermost first, mirroring the order the walk would have released them.
793
+ while (conv->frame_count > 0)
794
+ conv_frame_pop(conv);
795
+ if (conv->frames != conv->frames_inline)
796
+ xfree(conv->frames);
797
+ for (long i = 0; i < conv->pinned_count; i++)
798
+ JS_FreeValue(conv->ctx, conv->pinned[i]);
799
+ if (conv->pinned != conv->pinned_inline)
800
+ xfree(conv->pinned);
801
+ return Qnil;
802
+ }
803
+
804
+ // The straight-line sibling of ConvFrame. A block that renders a diagnostic
805
+ // takes several JS references at once — a name, a message, a stack — and keeps
806
+ // every one of them live while it builds Ruby objects from them, so there is
807
+ // nothing to hand back between iterations and no recursion to unwind. It gives
808
+ // each reference to a hold as it takes it, and the ensure that owns the hold
809
+ // releases the set.
810
+ //
811
+ // Raising past the frees is not hypothetical in these blocks. They run while an
812
+ // error is already being handled, so their Ruby allocations are the ones most
813
+ // likely to be short of memory, and every one of them can longjmp.
814
+ #define JS_HOLD_INLINE 16
815
+
816
+ typedef struct
817
+ {
818
+ enum
819
+ {
820
+ HELD_VALUE, // JSValue, released with JS_FreeValue
821
+ HELD_CSTRING, // JS_ToCString result, released with JS_FreeCString
822
+ HELD_BUFFER // xmalloc'd, released with xfree
823
+ } kind;
824
+ union
825
+ {
826
+ JSValue j_val;
827
+ const char *str;
828
+ char *buf;
829
+ } as;
830
+ } JsHeld;
831
+
832
+ typedef struct
833
+ {
834
+ JSContext *ctx;
835
+ JsHeld *held;
836
+ long count;
837
+ long capacity;
838
+ // Set when a string conversion was substituted away because the deadline
839
+ // fired inside it. Sticky for the life of the hold rather than per-read: the
840
+ // fact it records is about the evaluation, not about one property.
841
+ bool interrupted;
842
+ JsHeld held_inline[JS_HOLD_INLINE];
843
+ } JsHold;
844
+
845
+ static void js_hold_init(JsHold *hold, JSContext *ctx)
846
+ {
847
+ hold->ctx = ctx;
848
+ hold->held = hold->held_inline;
849
+ hold->count = 0;
850
+ hold->capacity = JS_HOLD_INLINE;
851
+ hold->interrupted = false;
852
+ }
853
+
854
+ // The deepest block below holds eight references at once — an exception, three
855
+ // property values, their three strings and a formatted headline — so the growth
856
+ // path is a backstop with room to spare rather than something a site relies on.
857
+ static JsHeld *js_hold_slot(JsHold *hold)
858
+ {
859
+ if (hold->count == hold->capacity)
860
+ {
861
+ long capacity = hold->capacity * 2;
862
+ JsHeld *held = xmalloc2(capacity, sizeof(JsHeld));
863
+ JsHeld *previous = hold->held;
864
+ memcpy(held, previous, hold->count * sizeof(JsHeld));
865
+ hold->held = held;
866
+ hold->capacity = capacity;
867
+ if (previous != hold->held_inline)
868
+ xfree(previous);
869
+ }
870
+ return &hold->held[hold->count++];
871
+ }
872
+
873
+ static JSValue js_hold_value(JsHold *hold, JSValue j_val)
874
+ {
875
+ JsHeld *slot = js_hold_slot(hold);
876
+ slot->kind = HELD_VALUE;
877
+ slot->as.j_val = j_val;
878
+ return j_val;
879
+ }
880
+
881
+ static const char *js_hold_own_cstring(JsHold *hold, const char *str)
882
+ {
883
+ JsHeld *slot = js_hold_slot(hold);
884
+ slot->kind = HELD_CSTRING;
885
+ slot->as.str = str;
886
+ return str;
887
+ }
888
+
889
+ // Whether the evaluation has run past the budget it was armed with. QuickJS
890
+ // throws the interrupt as InternalError("interrupted"), but recognising it by
891
+ // those strings would mean two things this cannot afford: they are strings any
892
+ // guest can write, so a script could relabel its own error as a timeout on a VM
893
+ // whose budget is nowhere near spent, and reading them off the thrown object
894
+ // runs that object's getters — which can reach a Ruby bridge, leaving this with
895
+ // an exception it has nowhere to put and would pin in alive_objects for the
896
+ // life of the VM. The clock answers the same question and nothing guest-written
897
+ // runs to answer it.
898
+ static bool eval_budget_lapsed(VMData *data)
899
+ {
900
+ return data->eval_timer_armed && eval_elapsed_ms(data->eval_time) >= data->eval_time->limit_ms;
901
+ }
902
+
903
+ bool eval_budget_lapsed_now(JSContext *ctx)
904
+ {
905
+ return eval_budget_lapsed(JS_GetContextOpaque(ctx));
906
+ }
907
+
908
+ // JS_ToCString converts through the value's own toString, so it answers NULL
909
+ // whenever that throws — a getter that raises, a Symbol, a Proxy that refuses —
910
+ // and not only when it runs out of memory. This keeps the NULL, for the two
911
+ // readers that render a value's absence differently from any string that could
912
+ // stand in for it.
913
+ static const char *js_hold_cstring_or_null(JsHold *hold, JSValue j_val)
914
+ {
915
+ const char *str = JS_ToCString(hold->ctx, j_val);
916
+ if (str != NULL)
917
+ return js_hold_own_cstring(hold, str);
918
+
919
+ // The conversion threw, and what was thrown decides who gets told. A throw
920
+ // carrying a Ruby exception is a bridge reporting a host failure — the user's
921
+ // block raising, or the ThreadError a dispose! mid-conversion owes its caller
922
+ // — and it has to come back out. find_ruby_error is also the only thing that
923
+ // takes the exception out of alive_objects, so discarding the throw instead
924
+ // would pin it there for the life of the VM at a rate the guest picks.
925
+ //
926
+ // Anything else is the value simply having no string form — a Symbol, a
927
+ // toString written to throw — and the caller substitutes for it. What is worth
928
+ // remembering is not the throw but the clock: a read the budget outlived says
929
+ // nothing about the value and everything about the evaluation.
930
+ JSValue j_pending = js_hold_value(hold, JS_GetException(hold->ctx));
931
+ VALUE r_ruby_error = find_ruby_error(hold->ctx, j_pending);
932
+ if (!NIL_P(r_ruby_error))
933
+ rb_exc_raise(r_ruby_error);
934
+
935
+ if (eval_budget_lapsed(JS_GetContextOpaque(hold->ctx)))
936
+ hold->interrupted = true;
937
+
938
+ return NULL;
939
+ }
940
+
941
+ // Callers get a string they can always print, compare and hand to Ruby. The
942
+ // fallback is a literal and so is deliberately not held.
943
+ static const char *js_hold_cstring(JsHold *hold, JSValue j_val, const char *fallback)
944
+ {
945
+ const char *str = js_hold_cstring_or_null(hold, j_val);
946
+ return str != NULL ? str : fallback;
947
+ }
948
+
949
+ // snprintf reports the length it would have written, and a negative return
950
+ // means the format itself failed — which, handed to a malloc as `length + 1`,
951
+ // asks for a block the size of the address space. xmalloc raises rather than
952
+ // answering NULL, so the buffer is either usable or never exists.
953
+ static char *js_hold_format(JsHold *hold, const char *format, ...) __attribute__((format(printf, 2, 3)));
954
+
955
+ static char *js_hold_format(JsHold *hold, const char *format, ...)
956
+ {
957
+ va_list args;
958
+ va_start(args, format);
959
+ int length = vsnprintf(NULL, 0, format, args);
960
+ va_end(args);
961
+ if (length < 0)
962
+ length = 0;
963
+
964
+ // The slot is filled in before the allocation that could raise past it, so
965
+ // an out-of-memory here leaves the hold holding a NULL rather than a kind it
966
+ // never got round to setting.
967
+ JsHeld *slot = js_hold_slot(hold);
968
+ slot->kind = HELD_BUFFER;
969
+ slot->as.buf = NULL;
970
+ char *buf = slot->as.buf = xmalloc(length + 1);
971
+
972
+ va_start(args, format);
973
+ vsnprintf(buf, length + 1, format, args);
974
+ va_end(args);
975
+ return buf;
976
+ }
977
+
978
+ // Leaves the hold empty and usable, so a block that finishes with one set and
979
+ // starts another can call this between them, and the ensure that calls it again
980
+ // afterwards finds nothing left to do.
981
+ static VALUE js_hold_release(VALUE r_hold)
982
+ {
983
+ JsHold *hold = (JsHold *)r_hold;
984
+ // Newest first, mirroring the order the block would have released them.
985
+ while (hold->count > 0)
986
+ {
987
+ JsHeld *slot = &hold->held[--hold->count];
988
+ switch (slot->kind)
989
+ {
990
+ case HELD_VALUE:
991
+ JS_FreeValue(hold->ctx, slot->as.j_val);
992
+ break;
993
+ case HELD_CSTRING:
994
+ JS_FreeCString(hold->ctx, slot->as.str);
995
+ break;
996
+ case HELD_BUFFER:
997
+ xfree(slot->as.buf);
998
+ break;
999
+ }
1000
+ }
1001
+ if (hold->held != hold->held_inline)
1002
+ xfree(hold->held);
1003
+ hold->held = hold->held_inline;
1004
+ hold->capacity = JS_HOLD_INLINE;
1005
+ return Qnil;
1006
+ }
1007
+
1008
+ // Stands in wherever a value refused to convert to a string, so the diagnostic
1009
+ // says so rather than printing "(null)" or dereferencing it.
1010
+
1011
+ // A budget that lapsed inside one of the reads outranks whatever the script was
1012
+ // in the middle of saying. Rendering happens after the evaluation is over, so
1013
+ // unlike the log path there is no next interrupt check to report it again: let
1014
+ // the substituted read stand on its own and the caller is handed a plain
1015
+ // RuntimeError for a run that ran out of time.
1016
+ static VALUE r_interrupted_error(void)
1017
+ {
1018
+ VALUE r_message = rb_str_new2("Code evaluation is interrupted by the timeout or something");
1019
+ return rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_INTERRUPTED_ERROR), rb_intern("new"), 2, r_message, Qnil);
1020
+ }
1021
+
1022
+ static void raise_if_interrupted(JsHold *hold)
1023
+ {
1024
+ if (hold->interrupted)
1025
+ rb_exc_raise(r_interrupted_error());
1026
+ }
1027
+
1028
+ struct js_exception_render
1029
+ {
1030
+ JSContext *ctx;
1031
+ // Whether the exception is the evaluation's own result. Only then is it news:
1032
+ // it gets the "Uncaught" row the console would have printed, and an
1033
+ // out-of-memory in it condemns the VM. An exception raised part-way through
1034
+ // converting a value that did return is on its way to the caller as that
1035
+ // call's error — telling the log listener it went uncaught would be the
1036
+ // opposite of what happened, and the heap it was found on is the heap of a
1037
+ // run that finished.
1038
+ bool uncaught;
1039
+ JsHold hold;
1040
+ };
1041
+
1042
+ static VALUE js_exception_render_run(VALUE r_render)
1043
+ {
1044
+ struct js_exception_render *render = (struct js_exception_render *)r_render;
1045
+ JSContext *ctx = render->ctx;
1046
+ JsHold *hold = &render->hold;
1047
+ VMData *data = JS_GetContextOpaque(ctx);
1048
+
1049
+ JSValue j_exceptionVal = js_hold_value(hold, JS_GetException(ctx));
1050
+
1051
+ if (!JS_IsError(ctx, j_exceptionVal))
1052
+ {
1053
+ // A thrown string, number or bare object: there is no name or stack to ask
1054
+ // for, only what the value itself says.
1055
+ const char *errorMessage = js_hold_cstring(hold, j_exceptionVal, QUICKJSRB_UNRENDERABLE);
1056
+ if (render->uncaught)
1057
+ {
1058
+ VALUE r_headline = rb_str_new2(js_hold_format(hold, "Uncaught '%s'", errorMessage));
1059
+ dispatch_log(data, "error", rb_ary_new3(1, r_log_body_new(r_headline, r_headline)));
1060
+ }
1061
+ raise_if_interrupted(hold);
1062
+
1063
+ rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"), 2, rb_str_new2(errorMessage), Qnil));
1064
+ }
1065
+
1066
+ VALUE r_maybe_ruby_error = find_ruby_error(ctx, j_exceptionVal);
1067
+ if (!NIL_P(r_maybe_ruby_error))
1068
+ rb_exc_raise(r_maybe_ruby_error);
1069
+ // will support other errors like just returning an instance of Error
1070
+
1071
+ JSValue j_errorClassName = js_hold_value(hold, JS_GetPropertyStr(ctx, j_exceptionVal, "name"));
1072
+ const char *readClassName = js_hold_cstring_or_null(hold, j_errorClassName);
1073
+ // "Error" is a workable default for picking a Ruby class, which has to resolve
1074
+ // to something. It is not an answer to what the JS side called this, so it
1075
+ // stops here and js_name says nothing instead of naming a class nobody named.
1076
+ const char *errorClassName = readClassName != NULL ? readClassName : "Error";
1077
+ VALUE r_error_name = readClassName != NULL ? rb_str_new2(readClassName) : Qnil;
1078
+
1079
+ JSValue j_errorClassMessage = js_hold_value(hold, JS_GetPropertyStr(ctx, j_exceptionVal, "message"));
1080
+ const char *errorClassMessage = js_hold_cstring(hold, j_errorClassMessage, QUICKJSRB_UNRENDERABLE);
1081
+
1082
+ JSValue j_stackTrace = js_hold_value(hold, JS_GetPropertyStr(ctx, j_exceptionVal, "stack"));
1083
+ // Empty rather than a placeholder: this one becomes a Ruby backtrace, and an
1084
+ // apology reads as a frame there.
1085
+ const char *stackTrace = js_hold_cstring(hold, j_stackTrace, "");
1086
+
1087
+ if (render->uncaught)
1088
+ {
1089
+ VALUE r_headline = rb_str_new2(js_hold_format(hold, "Uncaught %s: %s\n%s", errorClassName, errorClassMessage, stackTrace));
1090
+ dispatch_log(data, "error", rb_ary_new3(1, r_log_body_new(r_headline, r_headline)));
1091
+ }
1092
+ raise_if_interrupted(hold);
1093
+
1094
+ VALUE r_error_class, r_error_message = rb_str_new2(errorClassMessage);
1095
+ VALUE r_backtrace = r_backtrace_from_js_stack(stackTrace);
1096
+ if (is_native_error_name(errorClassName))
1097
+ {
1098
+ r_error_class = QUICKJSRB_ERROR_FOR(errorClassName);
1099
+ }
1100
+ else if (strcmp(errorClassName, "InternalError") == 0 && strstr(errorClassMessage, "interrupted") != NULL)
1101
+ {
1102
+ r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_INTERRUPTED_ERROR);
1103
+ r_error_message = rb_str_new2("Code evaluation is interrupted by the timeout or something");
1104
+ }
1105
+ else if (strcmp(errorClassName, "Quickjs::InterruptedError") == 0)
1106
+ {
1107
+ r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_INTERRUPTED_ERROR);
1108
+ }
1109
+ else if (strcmp(errorClassName, "InternalError") == 0 && strstr(errorClassMessage, "out of memory") != NULL)
1110
+ {
1111
+ // Once OOM has fired, the QuickJS heap is in a state where another
1112
+ // throw inside the parser-error path can corrupt the shape table and
1113
+ // segfault. Mark the VM so further eval/call calls refuse cleanly.
1114
+ //
1115
+ // This is the one thing the uncaught path does not keep to itself. Running
1116
+ // out of memory is a fact about the heap, not about who was asking: a
1117
+ // getter on an object the evaluation successfully returned allocates on the
1118
+ // same heap as the evaluation did, and `({get x() { return new
1119
+ // Array(2_000_000).fill(0) }})` reaches OOM here with the result already in
1120
+ // hand. Both strings are guest-writable, so a forged InternalError condemns
1121
+ // the VM too — but that is reachable from any getter and always has been,
1122
+ // and refusing to latch here would trade a real guard for no ground.
1123
+ data->oom_poisoned = true;
1124
+ r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR);
1125
+ }
1126
+ else
1127
+ {
1128
+ r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR);
1129
+ }
1130
+
1131
+ VALUE r_exc = rb_funcall(r_error_class, rb_intern("new"), 2, r_error_message, r_error_name);
1132
+ if (!NIL_P(r_backtrace))
1133
+ rb_funcall(r_exc, rb_intern("set_backtrace"), 1, r_backtrace);
1134
+ rb_exc_raise(r_exc);
1135
+ return Qnil; // rb_exc_raise does not return
1136
+ }
1137
+
1138
+ // Renders the context's pending exception into a Ruby one and raises it. Never
1139
+ // returns, so the hold is released by the ensure on the way past.
1140
+ static VALUE raise_rendered_js_exception(JSContext *ctx, bool uncaught)
1141
+ {
1142
+ struct js_exception_render render;
1143
+ render.ctx = ctx;
1144
+ render.uncaught = uncaught;
1145
+ js_hold_init(&render.hold, ctx);
1146
+ return rb_ensure(js_exception_render_run, (VALUE)&render, js_hold_release, (VALUE)&render.hold);
1147
+ }
1148
+
1149
+ // The exception nothing caught: it is the evaluation's whole result.
1150
+ static VALUE raise_uncaught_js_exception(JSContext *ctx)
1151
+ {
1152
+ return raise_rendered_js_exception(ctx, true);
1153
+ }
1154
+
1155
+ // The exception a conversion ran into on a value that did return — a getter, a
1156
+ // toString, an atom that would not allocate. The caller of eval_code hears it
1157
+ // as that call's error and nobody else needs telling.
1158
+ static VALUE raise_js_exception(JSContext *ctx)
1159
+ {
1160
+ return raise_rendered_js_exception(ctx, false);
1161
+ }
1162
+
1163
+ struct js_bigint_conversion
1164
+ {
1165
+ JSContext *ctx;
1166
+ JSValue j_val;
1167
+ JsHold hold;
1168
+ };
1169
+
1170
+ static VALUE js_bigint_conversion_run(VALUE r_conversion)
1171
+ {
1172
+ struct js_bigint_conversion *conversion = (struct js_bigint_conversion *)r_conversion;
1173
+ JSContext *ctx = conversion->ctx;
1174
+ JsHold *hold = &conversion->hold;
1175
+
1176
+ // Unlike the diagnostic blocks, this one is converting a value and has
1177
+ // somewhere to put a throw: a BigInt whose toString raises owes the caller
1178
+ // that error, not a digit string invented to stand in for it.
1179
+ //
1180
+ // The read is checked before the call, not after: calling a value that is
1181
+ // JS_EXCEPTION throws "not a function" over the top of the error that is
1182
+ // being reported, so testing afterwards reports the wrong one.
1183
+ JSValue j_toStringFunc = js_hold_value(hold, JS_GetPropertyStr(ctx, conversion->j_val, "toString"));
1184
+ if (JS_IsException(j_toStringFunc))
1185
+ return raise_js_exception(ctx);
1186
+
1187
+ JSValue j_strigified = js_hold_value(hold, JS_Call(ctx, j_toStringFunc, conversion->j_val, 0, NULL));
1188
+ if (JS_IsException(j_strigified))
1189
+ return raise_js_exception(ctx);
1190
+
1191
+ const char *msg = JS_ToCString(ctx, j_strigified);
1192
+ if (msg == NULL)
1193
+ return raise_js_exception(ctx);
1194
+
1195
+ return rb_funcall(rb_str_new2(js_hold_own_cstring(hold, msg)), rb_intern("to_i"), 0);
1196
+ }
1197
+
295
1198
  VALUE to_rb_value(JSContext *ctx, JSValue j_val)
296
1199
  {
297
- return to_rb_value_inner(ctx, j_val, Qnil);
1200
+ // Only object graphs need the bookkeeping, and only they can recurse, so
1201
+ // primitives convert straight through rather than paying for the state and
1202
+ // the ensure. Every recursive call therefore has a non-NULL `conv`.
1203
+ if (JS_VALUE_GET_NORM_TAG(j_val) != JS_TAG_OBJECT)
1204
+ return to_rb_value_inner(ctx, j_val, NULL);
1205
+
1206
+ ConvState conv = {ctx, j_val, rb_hash_new(), NULL, 0, CONV_PINNED_INLINE};
1207
+ conv.pinned = conv.pinned_inline;
1208
+ conv.frames = conv.frames_inline;
1209
+ conv.frame_count = 0;
1210
+ conv.frame_capacity = CONV_FRAMES_INLINE;
1211
+ conv.substitute_unresolvable = 0;
1212
+ return rb_ensure(conv_run, (VALUE)&conv, conv_release, (VALUE)&conv);
298
1213
  }
299
1214
 
300
- static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited)
1215
+ // The same conversion, for a caller that has to answer with a value: a proxy
1216
+ // it cannot resolve becomes the placeholder rather than an exception, at
1217
+ // whatever depth it is found.
1218
+ VALUE to_rb_value_substituting(JSContext *ctx, JSValue j_val)
1219
+ {
1220
+ if (JS_VALUE_GET_NORM_TAG(j_val) != JS_TAG_OBJECT)
1221
+ return to_rb_value_inner(ctx, j_val, NULL);
1222
+
1223
+ ConvState conv = {ctx, j_val, rb_hash_new(), NULL, 0, CONV_PINNED_INLINE};
1224
+ conv.pinned = conv.pinned_inline;
1225
+ conv.frames = conv.frames_inline;
1226
+ conv.frame_count = 0;
1227
+ conv.frame_capacity = CONV_FRAMES_INLINE;
1228
+ conv.substitute_unresolvable = 1;
1229
+ return rb_ensure(conv_run, (VALUE)&conv, conv_release, (VALUE)&conv);
1230
+ }
1231
+
1232
+ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, ConvState *conv)
301
1233
  {
302
1234
  switch (JS_VALUE_GET_NORM_TAG(j_val))
303
1235
  {
@@ -341,15 +1273,61 @@ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited)
341
1273
  return Qnil;
342
1274
  }
343
1275
 
1276
+ // Asked before anything reads the value, and before the function branch
1277
+ // claims it. JS_IsArray answers 1 for an array, 0 for anything else, and
1278
+ // -1 when it cannot resolve a proxy. A revoked one is the reachable way
1279
+ // there, whatever its target was; a chain of live proxies more than 1000
1280
+ // deep is the other, and reports the stack overflow it left pending. Both
1281
+ // are values this conversion cannot see through, and the guest is told the
1282
+ // same thing by Array.isArray on either. Read as a boolean further down, that -1 took
1283
+ // the array path and a value the guest is forbidden to touch came back as
1284
+ // an ordinary empty Array, indistinguishable from a real one.
1285
+ //
1286
+ // Here rather than at the array dispatch because JS_IsFunction stays true
1287
+ // for a revoked proxy over a callable target: leaving it to that branch
1288
+ // reported the same thing through a toString it also refuses, which is a
1289
+ // second way to the same answer and, in substituting mode, a way past the
1290
+ // substitution.
1291
+ int is_array = JS_IsArray(ctx, j_val);
1292
+ if (is_array < 0)
1293
+ {
1294
+ if (conv != NULL && conv->substitute_unresolvable)
1295
+ {
1296
+ // Taken rather than left pending: nothing downstream will report it,
1297
+ // and an unrelated evaluation would otherwise be handed it.
1298
+ quickjsrb_drain_pending(ctx);
1299
+ return rb_str_new2(QUICKJSRB_UNRENDERABLE);
1300
+ }
1301
+ // The throw it left is what the guest would be told, so that is what the
1302
+ // caller is told, rendered and taken off the context. Answering nil
1303
+ // instead would collide with the value a cycle already converts to.
1304
+ return raise_js_exception(ctx);
1305
+ }
1306
+
344
1307
  if (JS_IsFunction(ctx, j_val))
345
1308
  {
346
- JSValue j_toStringFunc = JS_GetPropertyStr(ctx, j_val, "toString");
1309
+ // A user-defined toString is guest code and can reach a Ruby bridge that
1310
+ // raises, so the frame owns the function across the call.
1311
+ long depth = conv_frame_push(conv);
1312
+ JSValue j_toStringFunc = conv_borrow(conv, depth, JS_GetPropertyStr(ctx, j_val, "toString"));
1313
+ // Checked before the call for the same reason as the BigInt branch:
1314
+ // calling JS_EXCEPTION throws "not a function" over the real error.
1315
+ if (JS_IsException(j_toStringFunc))
1316
+ return raise_js_exception(ctx);
347
1317
  JSValue j_source = JS_Call(ctx, j_toStringFunc, j_val, 0, NULL);
348
- JS_FreeValue(ctx, j_toStringFunc);
1318
+ conv_return(conv, depth);
1319
+ conv_borrow(conv, depth, j_source);
349
1320
  const char *source = JS_ToCString(ctx, j_source);
350
- JS_FreeValue(ctx, j_source);
1321
+ // A toString that threw owes the caller that error rather than a
1322
+ // Quickjs::Function built from nothing — and when the throw came from a
1323
+ // bridge, it is the Ruby exception the bridge raised, which is how a
1324
+ // dispose! reached from a toString gets to report ThreadError. The frame
1325
+ // still holds j_source, so conv_release hands it back on the way out.
1326
+ if (source == NULL)
1327
+ return raise_js_exception(ctx);
1328
+ conv_borrow_key(conv, depth, source);
351
1329
  VALUE r_source = rb_str_new2(source);
352
- JS_FreeCString(ctx, source);
1330
+ conv_frame_pop(conv);
353
1331
  return rb_funcall(rb_path2class("Quickjs::Function"), rb_intern("new"), 1, r_source);
354
1332
  }
355
1333
 
@@ -365,8 +1343,17 @@ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited)
365
1343
 
366
1344
  // Check for Ruby object proxy (e.g., File proxy with rb_object_id on target)
367
1345
  {
368
- JSValue j_rb_id = JS_GetPropertyStr(ctx, j_val, "rb_object_id");
369
- if (JS_VALUE_GET_NORM_TAG(j_rb_id) == JS_TAG_INT || JS_VALUE_GET_NORM_TAG(j_rb_id) == JS_TAG_FLOAT64)
1346
+ // Own data only, like the other two readers: through the prototype chain
1347
+ // an accessor on Object.prototype answers for every object crossing back,
1348
+ // and one line of guest setup turns a returned Hash into whichever host
1349
+ // File or CryptoKey the guest already holds a handle for.
1350
+ JSValue j_rb_id = JS_UNDEFINED;
1351
+ bool has_handle = j_read_own_handle(ctx, j_val, &j_rb_id);
1352
+ // A Proxy trap can still answer that read and throw, and what it throws
1353
+ // can be a bridge reporting a host failure.
1354
+ if (!has_handle && JS_IsException(j_rb_id))
1355
+ quickjsrb_drain_pending(ctx);
1356
+ else if (has_handle && (JS_VALUE_GET_NORM_TAG(j_rb_id) == JS_TAG_INT || JS_VALUE_GET_NORM_TAG(j_rb_id) == JS_TAG_FLOAT64))
370
1357
  {
371
1358
  int64_t object_id;
372
1359
  JS_ToInt64(ctx, &object_id, j_rb_id);
@@ -374,7 +1361,7 @@ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited)
374
1361
  if (object_id > 0)
375
1362
  {
376
1363
  VMData *data = JS_GetContextOpaque(ctx);
377
- VALUE r_obj = rb_hash_aref(data->alive_objects, LONG2NUM(object_id));
1364
+ VALUE r_obj = rb_hash_aref(data->alive_objects, LL2NUM(object_id));
378
1365
  if (!NIL_P(r_obj) && !rb_obj_is_kind_of(r_obj, rb_eException))
379
1366
  return r_obj;
380
1367
  }
@@ -392,149 +1379,107 @@ static VALUE to_rb_value_inner(JSContext *ctx, JSValue j_val, VALUE r_visited)
392
1379
  return r_maybe_file;
393
1380
  }
394
1381
 
395
- // Below this point, conversion recurses into own properties / elements
396
- // via to_rb_value_inner. Track JS object pointers to break cycles —
397
- // re-entering the same object returns nil instead of blowing the stack.
398
- if (NIL_P(r_visited))
399
- r_visited = rb_hash_new();
400
- VALUE r_visit_key = ULL2NUM((uintptr_t)JS_VALUE_GET_PTR(j_val));
401
- if (RTEST(rb_hash_lookup(r_visited, r_visit_key)))
1382
+ // Below this point, conversion recurses into own properties / elements via
1383
+ // to_rb_value_inner. An object that is still on the current path is a cycle
1384
+ // and becomes nil; one that finished converting earlier is a shared
1385
+ // reference and yields the very same Ruby object again.
1386
+ VALUE r_key = ULL2NUM((uintptr_t)JS_VALUE_GET_PTR(j_val));
1387
+ VALUE r_seen = rb_hash_lookup2(conv->r_seen, r_key, Qundef);
1388
+ if (r_seen == CONV_IN_PROGRESS(conv))
402
1389
  return Qnil;
403
- rb_hash_aset(r_visited, r_visit_key, Qtrue);
404
-
405
- if (JS_IsArray(ctx, j_val))
406
- return js_array_to_rb(ctx, j_val, r_visited);
407
-
408
- if (js_is_plain_object(ctx, j_val))
409
- return js_plain_object_to_rb(ctx, j_val, r_visited);
410
-
411
- // Non-plain objects (Date, RegExp, Map, class instances, etc.).
412
- // If the object opts in to a JSON representation via toJSON (e.g. Date),
413
- // honour it — recurse on the returned value. Otherwise dump own enumerable
414
- // string-keyed properties; this is faster than the JSON round-trip and
415
- // preserves `undefined` values nested inside class instances.
416
- JSValue j_toJSON = JS_GetPropertyStr(ctx, j_val, "toJSON");
417
- if (JS_IsFunction(ctx, j_toJSON))
1390
+ if (r_seen != Qundef)
1391
+ return r_seen;
1392
+
1393
+ conv_pin(conv, j_val);
1394
+ rb_hash_aset(conv->r_seen, r_key, CONV_IN_PROGRESS(conv));
1395
+
1396
+ VALUE r_result;
1397
+ int memoize = 1;
1398
+ // Asked again. The answer taken at the top of this case is stale by now:
1399
+ // the handle read and the File check both run guest traps, and a proxy
1400
+ // that revokes itself from one of them was resolvable when we asked and is
1401
+ // not any more. Reading the old answer here put that value back on the
1402
+ // array path, which is the fabrication this is all about.
1403
+ is_array = JS_IsArray(ctx, j_val);
1404
+ if (is_array < 0)
418
1405
  {
419
- JSValue j_jsonValue = JS_Call(ctx, j_toJSON, j_val, 0, NULL);
420
- JS_FreeValue(ctx, j_toJSON);
421
- VALUE r_result = to_rb_value_inner(ctx, j_jsonValue, r_visited);
422
- JS_FreeValue(ctx, j_jsonValue);
423
- return r_result;
424
- }
425
- JS_FreeValue(ctx, j_toJSON);
426
- return js_plain_object_to_rb(ctx, j_val, r_visited);
427
- }
428
- case JS_TAG_NULL:
429
- return Qnil;
430
- case JS_TAG_UNDEFINED:
431
- return QUICKJSRB_SYM(undefinedId);
432
- case JS_TAG_EXCEPTION:
433
- {
434
- JSValue j_exceptionVal = JS_GetException(ctx);
435
- if (JS_IsError(ctx, j_exceptionVal))
436
- {
437
- VALUE r_maybe_ruby_error = find_ruby_error(ctx, j_exceptionVal);
438
- if (!NIL_P(r_maybe_ruby_error))
1406
+ if (conv->substitute_unresolvable)
439
1407
  {
440
- JS_FreeValue(ctx, j_exceptionVal);
441
- rb_exc_raise(r_maybe_ruby_error);
442
- return Qnil;
1408
+ quickjsrb_drain_pending(ctx);
1409
+ // The in-progress mark goes with it: this value never entered the walk
1410
+ // it was marked into, and leaving the mark would make a second
1411
+ // occurrence answer the cycle check and convert to nil.
1412
+ rb_hash_delete(conv->r_seen, r_key);
1413
+ return rb_str_new2(QUICKJSRB_UNRENDERABLE);
443
1414
  }
444
-
445
- JSValue j_errorClassName = JS_GetPropertyStr(ctx, j_exceptionVal, "name");
446
- const char *errorClassName = JS_ToCString(ctx, j_errorClassName);
447
-
448
- JSValue j_errorClassMessage = JS_GetPropertyStr(ctx, j_exceptionVal, "message");
449
- const char *errorClassMessage = JS_ToCString(ctx, j_errorClassMessage);
450
-
451
- JSValue j_stackTrace = JS_GetPropertyStr(ctx, j_exceptionVal, "stack");
452
- const char *stackTrace = JS_ToCString(ctx, j_stackTrace);
453
- const char *headlineTemplate = "Uncaught %s: %s\n%s";
454
- int length = snprintf(NULL, 0, headlineTemplate, errorClassName, errorClassMessage, stackTrace);
455
- char *headline = (char *)malloc(length + 1);
456
- snprintf(headline, length + 1, headlineTemplate, errorClassName, errorClassMessage, stackTrace);
457
-
458
- VMData *data = JS_GetContextOpaque(ctx);
459
- VALUE r_headline = rb_str_new2(headline);
460
- dispatch_log(data, "error", rb_ary_new3(1, r_log_body_new(r_headline, r_headline)));
461
- free(headline);
462
-
463
- VALUE r_error_class, r_error_message = rb_str_new2(errorClassMessage);
464
- VALUE r_error_name = rb_str_new2(errorClassName);
465
- VALUE r_backtrace = r_backtrace_from_js_stack(stackTrace);
466
- if (is_native_error_name(errorClassName))
467
- {
468
- r_error_class = QUICKJSRB_ERROR_FOR(errorClassName);
469
- }
470
- else if (strcmp(errorClassName, "InternalError") == 0 && strstr(errorClassMessage, "interrupted") != NULL)
471
- {
472
- r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_INTERRUPTED_ERROR);
473
- r_error_message = rb_str_new2("Code evaluation is interrupted by the timeout or something");
474
- }
475
- else if (strcmp(errorClassName, "Quickjs::InterruptedError") == 0)
476
- {
477
- r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_INTERRUPTED_ERROR);
478
- }
479
- else if (strcmp(errorClassName, "InternalError") == 0 && strstr(errorClassMessage, "out of memory") != NULL)
1415
+ return raise_js_exception(ctx);
1416
+ }
1417
+ if (is_array > 0)
1418
+ {
1419
+ r_result = js_array_to_rb(ctx, j_val, conv);
1420
+ }
1421
+ else if (js_is_plain_object(ctx, j_val))
1422
+ {
1423
+ r_result = js_plain_object_to_rb(ctx, j_val, conv);
1424
+ }
1425
+ else
1426
+ {
1427
+ // Non-plain objects (Date, RegExp, Map, class instances, etc.).
1428
+ // If the object opts in to a JSON representation via toJSON (e.g. Date),
1429
+ // honour it — recurse on the returned value. Otherwise dump own enumerable
1430
+ // string-keyed properties; this is faster than the JSON round-trip and
1431
+ // preserves `undefined` values nested inside class instances.
1432
+ long depth = conv_frame_push(conv);
1433
+ JSValue j_toJSON = conv_borrow(conv, depth, JS_GetPropertyStr(ctx, j_val, "toJSON"));
1434
+ if (JS_IsFunction(ctx, j_toJSON))
480
1435
  {
481
- // Once OOM has fired, the QuickJS heap is in a state where another
482
- // throw inside the parser-error path can corrupt the shape table and
483
- // segfault. Mark the VM so further eval/call calls refuse cleanly.
484
- data->oom_poisoned = true;
485
- r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR);
1436
+ // toJSON is guest code and can reach a Ruby bridge that raises, so the
1437
+ // frame holds the function across the call and the result across the
1438
+ // recursion.
1439
+ JSValue j_jsonValue = JS_Call(ctx, j_toJSON, j_val, 0, NULL);
1440
+ conv_return(conv, depth);
1441
+ conv_borrow(conv, depth, j_jsonValue);
1442
+ r_result = to_rb_value_inner(ctx, j_jsonValue, conv);
1443
+ conv_return(conv, depth);
1444
+ // A toJSON representation is recomputed for every occurrence instead of
1445
+ // being shared. It is the object's stand-in value rather than a
1446
+ // container this conversion built, so memoizing it would hand out the
1447
+ // same mutable String for a Date reached twice, and would claim
1448
+ // identity between two distinct JS objects whose toJSON returns the
1449
+ // same thing. JSON.stringify also calls toJSON once per occurrence.
1450
+ memoize = 0;
486
1451
  }
487
1452
  else
488
1453
  {
489
- r_error_class = QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR);
1454
+ conv_return(conv, depth);
1455
+ r_result = js_plain_object_to_rb(ctx, j_val, conv);
490
1456
  }
491
- JS_FreeValue(ctx, j_errorClassMessage);
492
- JS_FreeValue(ctx, j_errorClassName);
493
- JS_FreeValue(ctx, j_stackTrace);
494
- JS_FreeCString(ctx, stackTrace);
495
- JS_FreeCString(ctx, errorClassName);
496
- JS_FreeCString(ctx, errorClassMessage);
497
- JS_FreeValue(ctx, j_exceptionVal);
498
-
499
- VALUE r_exc = rb_funcall(r_error_class, rb_intern("new"), 2, r_error_message, r_error_name);
500
- if (!NIL_P(r_backtrace))
501
- rb_funcall(r_exc, rb_intern("set_backtrace"), 1, r_backtrace);
502
- rb_exc_raise(r_exc);
1457
+ conv_frame_pop(conv);
503
1458
  }
504
- else // exception without Error object
505
- {
506
- const char *errorMessage = JS_ToCString(ctx, j_exceptionVal);
507
- const char *headlineTemplate = "Uncaught '%s'";
508
- int length = snprintf(NULL, 0, headlineTemplate, errorMessage);
509
- char *headline = (char *)malloc(length + 1);
510
- snprintf(headline, length + 1, headlineTemplate, errorMessage);
511
1459
 
512
- VMData *data = JS_GetContextOpaque(ctx);
513
- VALUE r_headline = rb_str_new2(headline);
514
- dispatch_log(data, "error", rb_ary_new3(1, r_log_body_new(r_headline, r_headline)));
515
-
516
- free(headline);
517
-
518
- VALUE r_error_message = rb_sprintf("%s", errorMessage);
519
- JS_FreeCString(ctx, errorMessage);
520
- JS_FreeValue(ctx, j_exceptionVal);
521
- rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"), 2, r_error_message, Qnil));
522
- }
523
- return Qnil;
1460
+ if (memoize)
1461
+ rb_hash_aset(conv->r_seen, r_key, r_result);
1462
+ else
1463
+ rb_hash_delete(conv->r_seen, r_key);
1464
+ return r_result;
524
1465
  }
1466
+ case JS_TAG_NULL:
1467
+ return Qnil;
1468
+ case JS_TAG_UNDEFINED:
1469
+ return QUICKJSRB_SYM(undefinedId);
1470
+ case JS_TAG_EXCEPTION:
1471
+ // conv is NULL only for the value to_rb_value was handed, which is the
1472
+ // evaluation's result; anything reached through a property read is part-way
1473
+ // through a walk and belongs to the caller, not to the console.
1474
+ return conv == NULL ? raise_uncaught_js_exception(ctx) : raise_js_exception(ctx);
525
1475
  case JS_TAG_BIG_INT:
526
1476
  case JS_TAG_SHORT_BIG_INT:
527
1477
  {
528
- JSValue j_toStringFunc = JS_GetPropertyStr(ctx, j_val, "toString");
529
- JSValue j_strigified = JS_Call(ctx, j_toStringFunc, j_val, 0, NULL);
530
-
531
- const char *msg = JS_ToCString(ctx, j_strigified);
532
- VALUE r_str = rb_str_new2(msg);
533
- JS_FreeValue(ctx, j_toStringFunc);
534
- JS_FreeValue(ctx, j_strigified);
535
- JS_FreeCString(ctx, msg);
536
-
537
- return rb_funcall(r_str, rb_intern("to_i"), 0);
1478
+ struct js_bigint_conversion conversion;
1479
+ conversion.ctx = ctx;
1480
+ conversion.j_val = j_val;
1481
+ js_hold_init(&conversion.hold, ctx);
1482
+ return rb_ensure(js_bigint_conversion_run, (VALUE)&conversion, js_hold_release, (VALUE)&conversion.hold);
538
1483
  }
539
1484
  case JS_TAG_SYMBOL:
540
1485
  default:
@@ -584,6 +1529,14 @@ static char *quickjsrb_module_normalize(JSContext *ctx, const char *base_name, c
584
1529
  if (!NIL_P(r_cached_canonical))
585
1530
  return js_strdup(ctx, StringValueCStr(r_cached_canonical));
586
1531
 
1532
+ // A preloaded name is already in ctx->loaded_modules, so js_find_loaded_module
1533
+ // will hand back that module the moment we return the name. Resolving it here
1534
+ // keeps the user's loader out of it entirely: it is never asked for a module
1535
+ // the VM was constructed with, the way a browser's module map short-circuits
1536
+ // a fetch.
1537
+ if (RTEST(rb_hash_aref(data->preloaded_module_names, r_specifier)))
1538
+ return js_strdup(ctx, StringValueCStr(r_specifier));
1539
+
587
1540
  struct module_loader_call_args args = {data->module_loader, r_specifier, r_importer};
588
1541
  int state;
589
1542
  VALUE r_return = rb_protect(r_module_loader_call, (VALUE)&args, &state);
@@ -592,6 +1545,9 @@ static char *quickjsrb_module_normalize(JSContext *ctx, const char *base_name, c
592
1545
  VALUE r_error = rb_errinfo();
593
1546
  rb_set_errinfo(Qnil);
594
1547
  JSValue j_error = j_error_from_ruby_error(ctx, r_error);
1548
+ // JS_Throw would replace the throw that made this fail with the sentinel.
1549
+ if (JS_IsException(j_error))
1550
+ return NULL;
595
1551
  JS_Throw(ctx, j_error);
596
1552
  return NULL;
597
1553
  }
@@ -612,14 +1568,18 @@ static char *quickjsrb_module_normalize(JSContext *ctx, const char *base_name, c
612
1568
  {
613
1569
  r_source = rb_hash_aref(r_return, ID2SYM(rb_intern("code")));
614
1570
  r_canonical = rb_hash_aref(r_return, ID2SYM(rb_intern("as")));
615
- if (!RB_TYPE_P(r_source, T_STRING))
1571
+ if (!RB_TYPE_P(r_canonical, T_STRING))
616
1572
  {
617
- JS_ThrowTypeError(ctx, "module loader Hash must include code: (String, the module source)");
1573
+ JS_ThrowTypeError(ctx, "module loader Hash must include as: (String, the canonical module name)");
618
1574
  return NULL;
619
1575
  }
620
- if (!RB_TYPE_P(r_canonical, T_STRING))
1576
+ // `code:` is optional: omitting it makes the Hash a pure redirect, "resolve
1577
+ // this specifier to `as:`", which is meaningful exactly when that module is
1578
+ // already in the map and has no source left to provide. Requiring it would
1579
+ // force importmap-style loaders to invent a source value nothing reads.
1580
+ if (!NIL_P(r_source) && !RB_TYPE_P(r_source, T_STRING))
621
1581
  {
622
- JS_ThrowTypeError(ctx, "module loader Hash must include as: (String, the canonical module name)");
1582
+ JS_ThrowTypeError(ctx, "module loader Hash code: must be a String (the module source), or omitted to redirect to as:");
623
1583
  return NULL;
624
1584
  }
625
1585
  }
@@ -630,7 +1590,13 @@ static char *quickjsrb_module_normalize(JSContext *ctx, const char *base_name, c
630
1590
  return NULL;
631
1591
  }
632
1592
 
633
- rb_hash_aset(data->module_source_cache, r_canonical, r_source);
1593
+ // The loader can also land on a preloaded module the long way round: an
1594
+ // importmap-style scope maps a bare specifier to a canonical that was
1595
+ // preloaded. QuickJS finds it in ctx->loaded_modules and never calls the
1596
+ // load hook, so stashing the source it just handed us would leave an entry
1597
+ // nothing ever clears.
1598
+ if (!NIL_P(r_source) && !RTEST(rb_hash_aref(data->preloaded_module_names, r_canonical)))
1599
+ rb_hash_aset(data->module_source_cache, r_canonical, r_source);
634
1600
  rb_hash_aset(data->module_resolution_cache, r_key, r_canonical);
635
1601
 
636
1602
  return js_strdup(ctx, StringValueCStr(r_canonical));
@@ -644,8 +1610,12 @@ static JSModuleDef *quickjsrb_module_loader(JSContext *ctx, const char *module_n
644
1610
  VALUE r_source = rb_hash_aref(data->module_source_cache, r_canonical);
645
1611
  if (NIL_P(r_source))
646
1612
  {
647
- // Defensive: normalize populates this on every miss.
648
- JS_ThrowReferenceError(ctx, "module loader: no cached source for '%s'", module_name);
1613
+ // Reachable through a redirect: a Hash without `code:` resolves a specifier
1614
+ // onto another canonical without providing any source, which only works if
1615
+ // that module is already loaded — and if it were, QuickJS would have found
1616
+ // it and never called this hook. For every other path normalize stashes a
1617
+ // source on the way past, so arriving here means the redirect dangled.
1618
+ JS_ThrowReferenceError(ctx, "module loader has no source for '%s': a Hash without code: redirects to an already-loaded module, and this one isn't loaded", module_name);
649
1619
  return NULL;
650
1620
  }
651
1621
  // QuickJS won't call load again for this canonical — its own module cache
@@ -675,46 +1645,83 @@ static void register_module_loader_funcs(VMData *data)
675
1645
  JS_SetModuleLoaderFunc2(runtime, quickjsrb_module_normalize, quickjsrb_module_loader, js_module_check_attributes, NULL);
676
1646
  }
677
1647
 
678
- static VALUE r_exception_from_js_reason(JSContext *ctx, JSValueConst j_reason)
1648
+ struct js_reason_conversion
1649
+ {
1650
+ JSContext *ctx;
1651
+ JSValueConst j_reason;
1652
+ int64_t *peek_handle;
1653
+ JsHold hold;
1654
+ };
1655
+
1656
+ static VALUE js_reason_conversion_run(VALUE r_conversion)
679
1657
  {
1658
+ struct js_reason_conversion *conversion = (struct js_reason_conversion *)r_conversion;
1659
+ JSContext *ctx = conversion->ctx;
1660
+ JSValueConst j_reason = conversion->j_reason;
1661
+ JsHold *hold = &conversion->hold;
1662
+
680
1663
  if (JS_IsError(ctx, j_reason))
681
1664
  {
682
- VALUE r_maybe_ruby_error = find_ruby_error(ctx, j_reason);
1665
+ VALUE r_maybe_ruby_error = find_ruby_error_peeking(ctx, j_reason, conversion->peek_handle);
683
1666
  if (!NIL_P(r_maybe_ruby_error))
684
1667
  return r_maybe_ruby_error;
685
1668
 
686
- JSValue j_name = JS_GetPropertyStr(ctx, j_reason, "name");
687
- JSValue j_message = JS_GetPropertyStr(ctx, j_reason, "message");
688
- JSValue j_stack = JS_GetPropertyStr(ctx, j_reason, "stack");
689
- const char *name = JS_ToCString(ctx, j_name);
690
- const char *message = JS_ToCString(ctx, j_message);
691
- const char *stack = JS_ToCString(ctx, j_stack);
1669
+ JSValue j_name = js_hold_value(hold, JS_GetPropertyStr(ctx, j_reason, "name"));
1670
+ JSValue j_message = js_hold_value(hold, JS_GetPropertyStr(ctx, j_reason, "message"));
1671
+ JSValue j_stack = js_hold_value(hold, JS_GetPropertyStr(ctx, j_reason, "stack"));
1672
+ const char *readName = js_hold_cstring_or_null(hold, j_name);
1673
+ const char *name = readName != NULL ? readName : "Error";
1674
+ const char *message = js_hold_cstring(hold, j_message, QUICKJSRB_UNRENDERABLE);
1675
+ const char *stack = js_hold_cstring(hold, j_stack, "");
692
1676
 
693
1677
  VALUE r_class = is_native_error_name(name)
694
1678
  ? QUICKJSRB_ERROR_FOR(name)
695
1679
  : QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR);
1680
+ // js_name is left nil rather than given the default the class selection
1681
+ // needed, for the reason the renderer spells out: a name nobody could read
1682
+ // is not "Error".
696
1683
  VALUE r_exc = rb_funcall(r_class, rb_intern("new"), 2,
697
- rb_str_new2(message), rb_str_new2(name));
1684
+ rb_str_new2(message), readName != NULL ? rb_str_new2(readName) : Qnil);
698
1685
  VALUE r_backtrace = r_backtrace_from_js_stack(stack);
699
1686
  if (!NIL_P(r_backtrace))
700
1687
  rb_funcall(r_exc, rb_intern("set_backtrace"), 1, r_backtrace);
701
1688
 
702
- JS_FreeCString(ctx, name);
703
- JS_FreeCString(ctx, message);
704
- if (stack)
705
- JS_FreeCString(ctx, stack);
706
- JS_FreeValue(ctx, j_name);
707
- JS_FreeValue(ctx, j_message);
708
- JS_FreeValue(ctx, j_stack);
709
1689
  return r_exc;
710
1690
  }
711
1691
 
712
- const char *str = JS_ToCString(ctx, j_reason);
713
- VALUE r_exc = rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"),
714
- 2, rb_str_new2(str ? str : "(non-stringifiable rejection)"), Qnil);
715
- if (str)
716
- JS_FreeCString(ctx, str);
717
- return r_exc;
1692
+ const char *str = js_hold_cstring(hold, j_reason, "(non-stringifiable rejection)");
1693
+ return rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"),
1694
+ 2, rb_str_new2(str), Qnil);
1695
+ }
1696
+
1697
+ // The reason belongs to the tracker, which is a QuickJS host callback, so this
1698
+ // never raises out: the caller runs it under rb_protect and the hold gives the
1699
+ // references back on the way past.
1700
+ static VALUE r_exception_from_js_reason(JSContext *ctx, JSValueConst j_reason, int64_t *peek_handle)
1701
+ {
1702
+ struct js_reason_conversion conversion;
1703
+ conversion.ctx = ctx;
1704
+ conversion.j_reason = j_reason;
1705
+ conversion.peek_handle = peek_handle;
1706
+ js_hold_init(&conversion.hold, ctx);
1707
+ VALUE r_exc = rb_ensure(js_reason_conversion_run, (VALUE)&conversion, js_hold_release, (VALUE)&conversion.hold);
1708
+ // The flag outlives the release, which is why it is sticky: a budget that
1709
+ // lapsed while the reason was read is what the listener hears about, since
1710
+ // the tracker cannot raise out and there may be no next interrupt check.
1711
+ return conversion.hold.interrupted ? r_interrupted_error() : r_exc;
1712
+ }
1713
+
1714
+ struct rejection_reason_args
1715
+ {
1716
+ JSContext *ctx;
1717
+ JSValueConst j_reason;
1718
+ int64_t *peek_handle;
1719
+ };
1720
+
1721
+ static VALUE r_rejection_reason(VALUE r_args_val)
1722
+ {
1723
+ struct rejection_reason_args *args = (struct rejection_reason_args *)r_args_val;
1724
+ return r_exception_from_js_reason(args->ctx, args->j_reason, args->peek_handle);
718
1725
  }
719
1726
 
720
1727
  struct rejection_call_args
@@ -729,27 +1736,243 @@ static VALUE r_rejection_call(VALUE r_args_val)
729
1736
  return rb_funcall(args->proc, rb_intern("call"), 1, args->r_reason);
730
1737
  }
731
1738
 
1739
+ static uint32_t rejection_list_hash(JSValueConst promise, uint32_t mask)
1740
+ {
1741
+ uint64_t p = (uint64_t)(uintptr_t)JS_VALUE_GET_PTR(promise);
1742
+ return (uint32_t)((p * 0x9E3779B97F4A7C15ULL) >> 32) & mask;
1743
+ }
1744
+
1745
+ static void rejection_list_index_insert(RejectionList *list, uint32_t i)
1746
+ {
1747
+ uint32_t mask = list->cap * 2 - 1;
1748
+ uint32_t h = rejection_list_hash(list->items[i].promise, mask);
1749
+ while (list->index[h] != 0)
1750
+ h = (h + 1) & mask;
1751
+ list->index[h] = i + 1;
1752
+ }
1753
+
1754
+ // Compacts the live items into new storage and reindexes them.
1755
+ static bool rejection_list_rebuild(JSContext *ctx, RejectionList *list, uint32_t cap)
1756
+ {
1757
+ RejectionEntry *items = js_malloc(ctx, sizeof(RejectionEntry) * cap);
1758
+ uint32_t *index = js_mallocz(ctx, sizeof(uint32_t) * cap * 2);
1759
+ if (items == NULL || index == NULL)
1760
+ {
1761
+ js_free(ctx, items);
1762
+ js_free(ctx, index);
1763
+ JS_FreeValue(ctx, JS_GetException(ctx));
1764
+ return false;
1765
+ }
1766
+ uint32_t n = 0;
1767
+ for (uint32_t i = 0; i < list->count; i++)
1768
+ if (!JS_IsUndefined(list->items[i].promise))
1769
+ items[n++] = list->items[i];
1770
+ js_free(ctx, list->items);
1771
+ js_free(ctx, list->index);
1772
+ list->items = items;
1773
+ list->index = index;
1774
+ list->count = n;
1775
+ list->cap = cap;
1776
+ for (uint32_t i = 0; i < n; i++)
1777
+ rejection_list_index_insert(list, i);
1778
+ return true;
1779
+ }
1780
+
1781
+ static void rejection_list_add(JSContext *ctx, RejectionList *list, JSValueConst promise, VALUE reason,
1782
+ int64_t error_handle)
1783
+ {
1784
+ if (list->count == list->cap)
1785
+ {
1786
+ uint32_t cap = list->cap == 0 ? 8 : list->live * 2 > list->cap ? list->cap * 2 : list->cap;
1787
+ if (cap > (UINT32_MAX >> 2) || !rejection_list_rebuild(ctx, list, cap))
1788
+ return;
1789
+ }
1790
+ uint32_t i = list->count++;
1791
+ list->items[i] = (RejectionEntry){JS_DupValue(ctx, promise), reason, error_handle};
1792
+ list->live++;
1793
+ rejection_list_index_insert(list, i);
1794
+ }
1795
+
1796
+ // The index slot holding promise, or -1.
1797
+ static int64_t rejection_list_slot(const RejectionList *list, JSValueConst promise)
1798
+ {
1799
+ if (list == NULL || list->cap == 0)
1800
+ return -1;
1801
+ uint32_t mask = list->cap * 2 - 1;
1802
+ for (uint32_t h = rejection_list_hash(promise, mask); list->index[h] != 0; h = (h + 1) & mask)
1803
+ {
1804
+ if (list->index[h] != UINT32_MAX &&
1805
+ JS_VALUE_GET_PTR(list->items[list->index[h] - 1].promise) == JS_VALUE_GET_PTR(promise))
1806
+ return h;
1807
+ }
1808
+ return -1;
1809
+ }
1810
+
1811
+ static bool rejection_list_remove(JSContext *ctx, RejectionList *list, JSValueConst promise)
1812
+ {
1813
+ int64_t h = rejection_list_slot(list, promise);
1814
+ if (h < 0)
1815
+ return false;
1816
+ uint32_t i = list->index[h] - 1;
1817
+ JS_FreeValue(ctx, list->items[i].promise);
1818
+ list->items[i] = (RejectionEntry){JS_UNDEFINED, Qnil, 0};
1819
+ list->index[h] = UINT32_MAX;
1820
+ list->live--;
1821
+ return true;
1822
+ }
1823
+
1824
+ // Moves the oldest n live entries into out; the rest stay, in order.
1825
+ static void rejection_list_split_oldest(JSContext *ctx, RejectionList *list, uint32_t n, RejectionList *out)
1826
+ {
1827
+ if (n >= list->live)
1828
+ {
1829
+ *out = *list;
1830
+ memset(list, 0, sizeof(*list));
1831
+ return;
1832
+ }
1833
+ memset(out, 0, sizeof(*out));
1834
+ for (uint32_t i = 0; i < list->count && out->live < n; i++)
1835
+ {
1836
+ RejectionEntry entry = list->items[i];
1837
+ if (JS_IsUndefined(entry.promise))
1838
+ continue;
1839
+ uint32_t before = out->live;
1840
+ rejection_list_add(ctx, out, entry.promise, entry.reason, entry.error_handle);
1841
+ if (out->live == before)
1842
+ break; // out of memory: the rest stays pending
1843
+ rejection_list_remove(ctx, list, entry.promise);
1844
+ }
1845
+ }
1846
+
1847
+ // Also looks in the batch being notified, so a promise handled by then is skipped.
1848
+ static void pending_rejections_remove(VMData *data, JSValueConst promise)
1849
+ {
1850
+ if (JS_VALUE_GET_TAG(promise) != JS_TAG_OBJECT)
1851
+ return;
1852
+ if (!rejection_list_remove(data->context, &data->pending_rejections, promise))
1853
+ rejection_list_remove(data->context, data->notifying_rejections, promise);
1854
+ }
1855
+
1856
+ // The reason is read here, inside the JS that rejected and on its clock, as
1857
+ // it always was; only the delivery waits, since a handler may still be
1858
+ // attached later in the same checkpoint.
732
1859
  static void quickjsrb_promise_rejection_tracker(
733
1860
  JSContext *ctx, JSValueConst promise, JSValueConst reason,
734
1861
  JS_BOOL is_handled, void *opaque)
735
1862
  {
736
- if (is_handled)
737
- return;
738
-
739
1863
  VMData *data = JS_GetContextOpaque(ctx);
740
1864
  if (NIL_P(data->on_unhandled_rejection))
741
1865
  return;
742
1866
 
743
- VALUE r_reason = r_exception_from_js_reason(ctx, reason);
744
- struct rejection_call_args args = {data->on_unhandled_rejection, r_reason};
1867
+ if (is_handled)
1868
+ {
1869
+ pending_rejections_remove(data, promise);
1870
+ return;
1871
+ }
1872
+
1873
+ // Recorded before the reason is read, so a rejection the read makes comes
1874
+ // after it, and a handler the read attaches removes it.
1875
+ rejection_list_add(ctx, &data->pending_rejections, promise, Qnil, 0);
1876
+
1877
+ // A bridge error raised while reading the reason becomes the reason.
1878
+ int64_t error_handle = 0;
1879
+ struct rejection_reason_args reason_args = {ctx, reason, &error_handle};
745
1880
  int state;
746
- rb_protect(r_rejection_call, (VALUE)&args, &state);
1881
+ VALUE r_reason = rb_protect(r_rejection_reason, (VALUE)&reason_args, &state);
747
1882
  if (state)
748
1883
  {
749
- // Longjmping out of a QuickJS host callback corrupts the runtime, so
750
- // a raise inside the user's tracker has to be dropped on the floor.
1884
+ VALUE r_raised = rb_errinfo();
751
1885
  rb_set_errinfo(Qnil);
1886
+ // A Ruby `throw` leaves no exception to report.
1887
+ r_reason = rb_obj_is_kind_of(r_raised, rb_eException) ? r_raised : Qnil;
1888
+ error_handle = 0;
752
1889
  }
1890
+ int64_t h = rejection_list_slot(&data->pending_rejections, promise);
1891
+ if (h < 0)
1892
+ return;
1893
+ if (NIL_P(r_reason))
1894
+ {
1895
+ rejection_list_remove(ctx, &data->pending_rejections, promise);
1896
+ return;
1897
+ }
1898
+ RejectionEntry *entry = &data->pending_rejections.items[data->pending_rejections.index[h] - 1];
1899
+ entry->reason = r_reason;
1900
+ entry->error_handle = error_handle;
1901
+ }
1902
+
1903
+ // A promise the host awaits is handled by the host: its rejection is raised.
1904
+ static JSValue quickjsrb_host_await(JSContext *ctx, JSValue promise)
1905
+ {
1906
+ JSValue held = JS_DupValue(ctx, promise);
1907
+ JSValue ret = js_std_await(ctx, promise); // frees promise
1908
+ pending_rejections_remove(JS_GetContextOpaque(ctx), held);
1909
+ JS_FreeValue(ctx, held);
1910
+ return ret;
1911
+ }
1912
+
1913
+ static void notify_rejection_batch(VMData *data, RejectionList *batch)
1914
+ {
1915
+ data->notifying_rejections = batch;
1916
+ for (uint32_t i = 0; i < batch->count; i++)
1917
+ {
1918
+ RejectionEntry entry = batch->items[i];
1919
+ if (JS_IsUndefined(entry.promise))
1920
+ continue;
1921
+ if (entry.error_handle != 0)
1922
+ release_peeked_ruby_error(data, entry.error_handle, entry.reason);
1923
+
1924
+ struct rejection_call_args call_args = {data->on_unhandled_rejection, entry.reason};
1925
+ int state;
1926
+ rb_protect(r_rejection_call, (VALUE)&call_args, &state);
1927
+ if (state)
1928
+ rb_set_errinfo(Qnil);
1929
+ }
1930
+ data->notifying_rejections = NULL;
1931
+ }
1932
+
1933
+ // The rest stay pending, ahead of any made while notifying.
1934
+ static void quickjsrb_notify_oldest_rejections(VMData *data, uint32_t n)
1935
+ {
1936
+ if (NIL_P(data->on_unhandled_rejection) || n == 0)
1937
+ return;
1938
+ // $! is neither shown to the handler nor lost to its errors. A throw or a
1939
+ // kill in flight has no exception to put back, so its rejections wait.
1940
+ VALUE r_errinfo = rb_errinfo();
1941
+ if (!NIL_P(r_errinfo) && !rb_obj_is_kind_of(r_errinfo, rb_eException))
1942
+ return;
1943
+ rb_set_errinfo(Qnil);
1944
+ RejectionList batch;
1945
+ rejection_list_split_oldest(data->context, &data->pending_rejections, n, &batch);
1946
+ notify_rejection_batch(data, &batch);
1947
+ rejection_list_free(data->context, &batch);
1948
+ rb_set_errinfo(r_errinfo);
1949
+ }
1950
+
1951
+ // Reports every pending rejection, then once more for what the handler
1952
+ // rejected meanwhile, and no further. The second round is left to whoever
1953
+ // drains the queue if the first one queued jobs that may still run.
1954
+ static void quickjsrb_notify_all_rejections(VMData *data, bool jobs_may_run)
1955
+ {
1956
+ quickjsrb_notify_oldest_rejections(data, data->pending_rejections.live);
1957
+ if (jobs_may_run && JS_IsJobPending(JS_GetRuntime(data->context)))
1958
+ return;
1959
+ quickjsrb_notify_oldest_rejections(data, data->pending_rejections.live);
1960
+ }
1961
+
1962
+ // With jobs still queued, whoever drains them ends the checkpoint; until then
1963
+ // at most max_pending_rejections are carried over, the oldest reported first.
1964
+ static void quickjsrb_end_microtask_checkpoint(VMData *data)
1965
+ {
1966
+ if (data->oom_poisoned)
1967
+ return;
1968
+ uint32_t live = data->pending_rejections.live;
1969
+ if (!JS_IsJobPending(JS_GetRuntime(data->context)))
1970
+ quickjsrb_notify_all_rejections(data, true);
1971
+ else if (live > data->max_pending_rejections)
1972
+ quickjsrb_notify_oldest_rejections(data, live - data->max_pending_rejections);
1973
+ // Give back what an emptied list still holds, rather than keep its peak.
1974
+ if (data->pending_rejections.live == 0)
1975
+ rejection_list_free(data->context, &data->pending_rejections);
753
1976
  }
754
1977
 
755
1978
  static VALUE r_try_call_proc(VALUE r_try_args)
@@ -764,6 +1987,25 @@ static VALUE r_try_call_proc(VALUE r_try_args)
764
1987
  );
765
1988
  }
766
1989
 
1990
+ // The argument conversion, run under rb_protect by the bridge below. The
1991
+ // values are borrowed: QuickJS owns argv for the length of the call, and
1992
+ // to_rb_value does not consume what it is given.
1993
+ struct call_args_conv
1994
+ {
1995
+ JSContext *ctx;
1996
+ int argc;
1997
+ JSValueConst *argv;
1998
+ VALUE r_argv;
1999
+ };
2000
+
2001
+ static VALUE r_convert_call_args(VALUE r_work)
2002
+ {
2003
+ struct call_args_conv *work = (struct call_args_conv *)r_work;
2004
+ for (int i = 0; i < work->argc; i++)
2005
+ rb_ary_push(work->r_argv, to_rb_value(work->ctx, work->argv[i]));
2006
+ return Qnil;
2007
+ }
2008
+
767
2009
  static JSValue js_quickjsrb_call_global(JSContext *ctx, JSValueConst _this, int argc, JSValueConst *argv, int _magic, JSValue *func_data)
768
2010
  {
769
2011
  // func_data[0] holds the Ruby Symbol ID for the defined function (stored by
@@ -774,21 +2016,110 @@ static JSValue js_quickjsrb_call_global(JSContext *ctx, JSValueConst _this, int
774
2016
 
775
2017
  VMData *data = JS_GetContextOpaque(ctx);
776
2018
  VALUE r_proc = rb_hash_aref(data->defined_functions, ID2SYM((ID)key_id));
777
- // Shouldn't happen
2019
+ // Entries are never removed, and the block is recorded once the property is
2020
+ // installed, so the one way to get here is from inside that install: a
2021
+ // setter on the target that calls the value it was handed. Say so, rather
2022
+ // than report an internal fault to a caller whose setter did exactly that.
778
2023
  if (r_proc == Qnil)
779
2024
  {
780
- return JS_ThrowReferenceError(ctx, "Proc is not defined");
2025
+ return JS_ThrowReferenceError(ctx, "'%s' cannot be called yet: define_function has not finished installing it", rb_id2name((ID)key_id));
781
2026
  }
782
2027
 
783
2028
  VALUE r_call_args = rb_ary_new();
784
2029
  rb_ary_push(r_call_args, r_proc);
785
2030
 
2031
+ // Protected, like the call below it. Converting an argument runs guest code
2032
+ // and can raise, and this is inside a JSCFunction: an unprotected raise
2033
+ // unwinds through QuickJS, taking the values this frame holds with it, and
2034
+ // the guest cannot catch what every other error from this bridge is
2035
+ // catchable. Measured before this, a revoked proxy argument cost 7 QuickJS
2036
+ // objects and about 1.5KB per call, at a rate the guest picks.
786
2037
  VALUE r_argv = rb_ary_new();
787
- for (int i = 0; i < argc; i++)
2038
+ struct call_args_conv conv_work = {ctx, argc, argv, r_argv};
2039
+ int conv_failed;
2040
+ rb_protect(r_convert_call_args, (VALUE)&conv_work, &conv_failed);
2041
+ if (conv_failed)
788
2042
  {
789
- JSValue j_v = JS_DupValue(ctx, argv[i]);
790
- rb_ary_push(r_argv, to_rb_value(ctx, j_v));
791
- JS_FreeValue(ctx, j_v);
2043
+ VALUE r_conv_error = rb_errinfo();
2044
+ rb_set_errinfo(Qnil);
2045
+
2046
+ // Two things outrank whatever else the conversion raised, and neither is
2047
+ // the guest's to catch. rb_protect catches everything, so without this the
2048
+ // deadline came back as an ordinary Error and a script that swallowed it
2049
+ // returned a value for a run that had already overrun; and an
2050
+ // out-of-memory came back the same way, on a heap whose own latch says
2051
+ // further evaluation may segfault, leaving the guest free to keep
2052
+ // allocating until the next Ruby entry point refuses. The same two calls
2053
+ // js_poll_interrupts makes, which is what the log bridge does with its own
2054
+ // protect for the same reason.
2055
+ if (eval_budget_lapsed_now(ctx))
2056
+ {
2057
+ JS_ThrowInternalError(ctx, "interrupted");
2058
+ JS_SetUncatchableException(ctx, TRUE);
2059
+ return JS_EXCEPTION;
2060
+ }
2061
+ if (data->oom_poisoned)
2062
+ {
2063
+ // Uncatchable for the same reason, and said in its own words: reporting
2064
+ // this as an interruption would tell the caller the run ran out of time
2065
+ // when it ran out of memory, and the top-level renderer classifies by
2066
+ // the message.
2067
+ JS_ThrowInternalError(ctx, "out of memory");
2068
+ JS_SetUncatchableException(ctx, TRUE);
2069
+ return JS_EXCEPTION;
2070
+ }
2071
+
2072
+ // Only an Exception can be bridged: rb_protect reports every non-local
2073
+ // exit, and a Ruby throw leaves data in errinfo that j_error_from_ruby_error
2074
+ // would immediately send #message to. The promise rejection tracker guards
2075
+ // the same way.
2076
+ if (!rb_obj_is_kind_of(r_conv_error, rb_eException))
2077
+ return JS_ThrowInternalError(ctx, "quickjs: converting an argument was interrupted by a non-local exit");
2078
+
2079
+ // The capability is taken before the error is built, not after: building
2080
+ // the error publishes a handle into alive_objects, and a capability that
2081
+ // then failed to allocate would leave that entry with nothing able to read
2082
+ // it back out, pinning the Ruby exception for the life of the VM. The
2083
+ // async path below this function is ordered the same way for the same
2084
+ // reason.
2085
+ bool is_async = JS_ToBool(ctx, func_data[1]);
2086
+ JSValue resolving_funcs[2];
2087
+ JSValue promise = JS_UNDEFINED;
2088
+ if (is_async)
2089
+ {
2090
+ promise = JS_NewPromiseCapability(ctx, resolving_funcs);
2091
+ if (JS_IsException(promise))
2092
+ return JS_EXCEPTION;
2093
+ }
2094
+
2095
+ // Handed back the way the call's own failures are: as a JS error carrying
2096
+ // the host exception, so a guest that wrapped this in try/catch sees it
2097
+ // and the host still gets it back if the throw travels out. An async
2098
+ // function rejects with it rather than throwing at the call, since that is
2099
+ // the shape its caller was written against.
2100
+ JSValue j_conv_error = j_error_from_ruby_error(ctx, r_conv_error);
2101
+ if (JS_IsException(j_conv_error))
2102
+ {
2103
+ if (is_async)
2104
+ {
2105
+ JS_FreeValue(ctx, promise);
2106
+ JS_FreeValue(ctx, resolving_funcs[0]);
2107
+ JS_FreeValue(ctx, resolving_funcs[1]);
2108
+ }
2109
+ return JS_EXCEPTION;
2110
+ }
2111
+
2112
+ if (is_async)
2113
+ {
2114
+ JSValue j_settled = JS_Call(ctx, resolving_funcs[1], JS_UNDEFINED, 1, (JSValueConst *)&j_conv_error);
2115
+ JS_FreeValue(ctx, j_conv_error);
2116
+ JS_FreeValue(ctx, j_settled);
2117
+ JS_FreeValue(ctx, resolving_funcs[0]);
2118
+ JS_FreeValue(ctx, resolving_funcs[1]);
2119
+ return promise;
2120
+ }
2121
+
2122
+ return JS_Throw(ctx, j_conv_error);
792
2123
  }
793
2124
  rb_ary_push(r_call_args, r_argv);
794
2125
  rb_ary_push(r_call_args, ULONG2NUM(data->eval_time->limit_ms));
@@ -810,13 +2141,42 @@ static JSValue js_quickjsrb_call_global(JSContext *ctx, JSValueConst _this, int
810
2141
  if (sadnessHappened)
811
2142
  {
812
2143
  VALUE r_error = rb_errinfo();
2144
+ // Cleared, like every other recovery site here. Left set, it survives
2145
+ // eval_code returning normally: a guest that merely catches the throw
2146
+ // leaves the host's $! holding it, later raises chain to it through
2147
+ // cause (#126), and a script that does nothing else exits 1 with a
2148
+ // printed backtrace it never raised.
2149
+ rb_set_errinfo(Qnil);
813
2150
  j_result = j_error_from_ruby_error(ctx, r_error);
2151
+ // Rejecting with the sentinel would hand the guest a value it cannot
2152
+ // name, so the promise is left unsettled and the throw that produced it,
2153
+ // already pending, is what the caller gets. Freed on the way out with
2154
+ // everything else this block holds: returning from here directly would
2155
+ // leak the capability and keep its resolving functions alive for the
2156
+ // life of the runtime.
2157
+ if (JS_IsException(j_result))
2158
+ {
2159
+ JS_FreeValue(ctx, promise);
2160
+ JS_FreeValue(ctx, resolving_funcs[0]);
2161
+ JS_FreeValue(ctx, resolving_funcs[1]);
2162
+ return JS_EXCEPTION;
2163
+ }
814
2164
  ret_val = JS_Call(ctx, resolving_funcs[1], JS_UNDEFINED,
815
2165
  1, (JSValueConst *)&j_result);
816
2166
  }
817
2167
  else
818
2168
  {
819
2169
  j_result = to_js_value(ctx, r_result);
2170
+ // Resolving with the sentinel would put it inside the promise; the throw
2171
+ // that produced it is pending for the caller instead.
2172
+ if (JS_IsException(j_result))
2173
+ {
2174
+ JS_FreeValue(ctx, promise);
2175
+ JS_FreeValue(ctx, resolving_funcs[0]);
2176
+ JS_FreeValue(ctx, resolving_funcs[1]);
2177
+ return JS_EXCEPTION;
2178
+ }
2179
+
820
2180
  ret_val = JS_Call(ctx, resolving_funcs[0], JS_UNDEFINED,
821
2181
  1, (JSValueConst *)&j_result);
822
2182
  }
@@ -832,7 +2192,10 @@ static JSValue js_quickjsrb_call_global(JSContext *ctx, JSValueConst _this, int
832
2192
  if (sadnessHappened)
833
2193
  {
834
2194
  VALUE r_error = rb_errinfo();
2195
+ rb_set_errinfo(Qnil);
835
2196
  JSValue j_error = j_error_from_ruby_error(ctx, r_error);
2197
+ if (JS_IsException(j_error))
2198
+ return JS_EXCEPTION;
836
2199
  return JS_Throw(ctx, j_error);
837
2200
  }
838
2201
  else
@@ -914,19 +2277,23 @@ struct quickjsrb_log_call
914
2277
  JSValueConst *argv;
915
2278
  const char *severity;
916
2279
  JSValue result;
2280
+ // Set when the budget lapsed inside one of the logged values; read back on
2281
+ // the QuickJS side of the protect, where it can be thrown as the interrupt.
2282
+ bool interrupted;
917
2283
  };
918
2284
 
919
- // Runs under rb_protect (see js_quickjsrb_log_inner) so no Ruby raise —
920
- // to_rb_value on an unconvertible argument (e.g. a Promise nested inside an
921
- // array), allocation failure, or the user's on_log listener — can longjmp
922
- // through QuickJS's interpreter frames, or, on the pure path (where this
923
- // runs inside rb_thread_call_with_gvl), across the rb_thread_call_without_gvl
924
- // region — which would leak its buffers and leave gvl_released_js stuck.
925
- static VALUE r_build_and_dispatch_log(VALUE r_call)
2285
+ struct log_row_build
926
2286
  {
927
- struct quickjsrb_log_call *call = (struct quickjsrb_log_call *)r_call;
2287
+ struct quickjsrb_log_call *call;
2288
+ JsHold hold;
2289
+ };
2290
+
2291
+ static VALUE r_build_log_row(VALUE r_build)
2292
+ {
2293
+ struct log_row_build *build = (struct log_row_build *)r_build;
2294
+ struct quickjsrb_log_call *call = build->call;
928
2295
  JSContext *ctx = call->ctx;
929
- VMData *data = JS_GetContextOpaque(ctx);
2296
+ JsHold *hold = &build->hold;
930
2297
  VALUE r_row = rb_ary_new();
931
2298
  for (int i = 0; i < call->argc; i++)
932
2299
  {
@@ -938,40 +2305,57 @@ static VALUE r_build_and_dispatch_log(VALUE r_call)
938
2305
  }
939
2306
  else if (JS_IsError(ctx, j_logged))
940
2307
  {
941
- JSValue j_errorClassName = JS_GetPropertyStr(ctx, j_logged, "name");
942
- const char *errorClassName = JS_ToCString(ctx, j_errorClassName);
943
- JS_FreeValue(ctx, j_errorClassName);
944
-
945
- JSValue j_errorClassMessage = JS_GetPropertyStr(ctx, j_logged, "message");
946
- const char *errorClassMessage = JS_ToCString(ctx, j_errorClassMessage);
947
- JS_FreeValue(ctx, j_errorClassMessage);
948
-
949
- JSValue j_stackTrace = JS_GetPropertyStr(ctx, j_logged, "stack");
950
- const char *stackTrace = JS_ToCString(ctx, j_stackTrace);
951
- JS_FreeValue(ctx, j_stackTrace);
952
-
953
- const char *headlineTemplate = "%s: %s\n%s";
954
- int length = snprintf(NULL, 0, headlineTemplate, errorClassName, errorClassMessage, stackTrace);
955
- char *headline = (char *)malloc(length + 1);
956
- snprintf(headline, length + 1, headlineTemplate, errorClassName, errorClassMessage, stackTrace);
957
- JS_FreeCString(ctx, errorClassName);
958
- JS_FreeCString(ctx, errorClassMessage);
959
- JS_FreeCString(ctx, stackTrace);
960
-
961
- r_raw = rb_str_new2(headline);
962
- free(headline);
2308
+ JSValue j_errorClassName = js_hold_value(hold, JS_GetPropertyStr(ctx, j_logged, "name"));
2309
+ const char *errorClassName = js_hold_cstring(hold, j_errorClassName, "Error");
2310
+
2311
+ JSValue j_errorClassMessage = js_hold_value(hold, JS_GetPropertyStr(ctx, j_logged, "message"));
2312
+ const char *errorClassMessage = js_hold_cstring(hold, j_errorClassMessage, QUICKJSRB_UNRENDERABLE);
2313
+
2314
+ JSValue j_stackTrace = js_hold_value(hold, JS_GetPropertyStr(ctx, j_logged, "stack"));
2315
+ const char *stackTrace = js_hold_cstring(hold, j_stackTrace, "");
2316
+
2317
+ r_raw = rb_str_new2(js_hold_format(hold, "%s: %s\n%s", errorClassName, errorClassMessage, stackTrace));
963
2318
  }
964
2319
  else
965
2320
  {
966
- r_raw = to_rb_value(ctx, j_logged);
2321
+ r_raw = to_rb_value_substituting(ctx, j_logged);
967
2322
  }
968
- const char *body = JS_ToCString(ctx, j_logged);
969
- VALUE r_c = rb_str_new2(body);
970
- JS_FreeCString(ctx, body);
2323
+ VALUE r_c = rb_str_new2(js_hold_cstring(hold, j_logged, QUICKJSRB_UNRENDERABLE));
971
2324
 
972
2325
  rb_ary_push(r_row, r_log_body_new(r_raw, r_c));
2326
+
2327
+ // Each argument's references go back as its entry is finished, the way the
2328
+ // block released them one at a time before; the ensure covers a raise.
2329
+ js_hold_release((VALUE)hold);
973
2330
  }
974
2331
 
2332
+ return r_row;
2333
+ }
2334
+
2335
+ // Runs under rb_protect (see js_quickjsrb_log_inner) so no Ruby raise —
2336
+ // to_rb_value on an unconvertible argument (e.g. a Promise nested inside an
2337
+ // array), a bridge reporting through a logged value's toString, allocation
2338
+ // failure, or the user's on_log listener — can longjmp through QuickJS's
2339
+ // interpreter frames, or, on the pure path (where this runs inside
2340
+ // rb_thread_call_with_gvl), across the rb_thread_call_without_gvl region —
2341
+ // which would leak its buffers and leave gvl_released_js stuck.
2342
+ static VALUE r_build_and_dispatch_log(VALUE r_call)
2343
+ {
2344
+ struct quickjsrb_log_call *call = (struct quickjsrb_log_call *)r_call;
2345
+ VMData *data = JS_GetContextOpaque(call->ctx);
2346
+
2347
+ struct log_row_build build;
2348
+ build.call = call;
2349
+ js_hold_init(&build.hold, call->ctx);
2350
+ VALUE r_row = rb_ensure(r_build_log_row, (VALUE)&build, js_hold_release, (VALUE)&build.hold);
2351
+
2352
+ // Carried out rather than raised here: a Ruby exception would be bridged
2353
+ // into a catchable JS Error, and a guest wrapping console.log in try/catch
2354
+ // could swallow the timeout and pin one InterruptedError in alive_objects
2355
+ // per catch. The caller throws it as the interrupt QuickJS itself would.
2356
+ // Copied before the listener runs, since a listener that raises unwinds
2357
+ // past everything after it.
2358
+ call->interrupted = build.hold.interrupted;
975
2359
  r_call_log_listener(rb_ary_new3(2, data->log_listener, r_log_new(call->severity, r_row)));
976
2360
  return Qnil;
977
2361
  }
@@ -981,14 +2365,30 @@ static VALUE r_build_and_dispatch_log(VALUE r_call)
981
2365
  // JS exception instead of a cross-boundary longjmp.
982
2366
  static JSValue js_quickjsrb_log_inner(JSContext *ctx, int argc, JSValueConst *argv, const char *severity)
983
2367
  {
984
- struct quickjsrb_log_call call = {ctx, argc, argv, severity, JS_UNDEFINED};
2368
+ struct quickjsrb_log_call call = {ctx, argc, argv, severity, JS_UNDEFINED, false};
985
2369
  int error;
986
2370
  rb_protect(r_build_and_dispatch_log, (VALUE)&call, &error);
2371
+ if (call.interrupted)
2372
+ {
2373
+ // The lapse outranks a raise from the listener, as it does in the uncaught
2374
+ // renderer, where dispatch_log swallows the listener's raise on its own:
2375
+ // the budget is gone, and reporting the listener instead would hand the
2376
+ // guest a catchable error to repeat the overrun behind.
2377
+ if (error)
2378
+ rb_set_errinfo(Qnil);
2379
+ // The same two calls js_poll_interrupts makes, so the guest cannot catch it
2380
+ // and the top-level renderer classifies it exactly as a native timeout.
2381
+ JS_ThrowInternalError(ctx, "interrupted");
2382
+ JS_SetUncatchableException(ctx, TRUE);
2383
+ return JS_EXCEPTION;
2384
+ }
987
2385
  if (error)
988
2386
  {
989
2387
  VALUE r_error = rb_errinfo();
990
2388
  rb_set_errinfo(Qnil);
991
2389
  JSValue j_error = j_error_from_ruby_error(ctx, r_error);
2390
+ if (JS_IsException(j_error))
2391
+ return JS_EXCEPTION;
992
2392
  return JS_Throw(ctx, j_error);
993
2393
  }
994
2394
  return JS_UNDEFINED;
@@ -1060,35 +2460,49 @@ static JSValue js_console_error(JSContext *ctx, JSValueConst this, int argc, JSV
1060
2460
  return js_quickjsrb_log(ctx, argc, argv, "error");
1061
2461
  }
1062
2462
 
1063
- // Run polyfill bytecode load + eval without the GVL so a background
1064
- // warmer thread can populate a VM pool in parallel with the main thread
1065
- // on multi-core hosts.
2463
+ // Run bytecode load + eval without the GVL so background threads (warmer
2464
+ // pools populating VMs, per-thread Runnable#run) proceed in parallel with
2465
+ // the main thread on multi-core hosts.
1066
2466
  //
1067
- // Two call sites use this helper:
2467
+ // Four call sites use the run_bytecode_release_gvl wrapper below:
1068
2468
  //
1069
- // 1. vm_m_initialize — pre-built bytecode (file / encoding / url) embedded
1070
- // as static C constants. setTimeout (FEATURE_TIMEOUT) and the File
1071
- // proxy (POLYFILL_FILE, registered ahead of the encoding/url loads)
1072
- // can already be live here, so the release is safe not because
1073
- // nothing is registered, but because a load never runs bridge code:
1074
- // js_quickjsrb_set_timeout only enqueues (pure C; the Ruby-calling
1075
- // js_delay_and_eval_job runs later, under a GVL-held drain/await), a
1076
- // load never drains the job queue, and the bundled polyfill
1077
- // top-levels (built from polyfills/src in this repo) don't call the
1078
- // File proxy. That audit is the invariant to preserve when rebuilding
1079
- // bundles or reordering vm_m_initialize.
2469
+ // 1. vm_m_initialize — pre-built polyfill bytecode (file / encoding /
2470
+ // url) embedded as static C constants. setTimeout (FEATURE_TIMEOUT)
2471
+ // and the File proxy (POLYFILL_FILE, registered ahead of the
2472
+ // encoding/url loads) can already be live here, so the release is
2473
+ // safe not because nothing is registered, but because a load never
2474
+ // runs bridge code: js_quickjsrb_set_timeout only enqueues (pure C;
2475
+ // the Ruby-calling js_delay_and_eval_job runs later, under a
2476
+ // GVL-held drain/await), a load never drains the job queue, and the
2477
+ // bundled polyfill top-levels (built from polyfills/src in this
2478
+ // repo) don't call the File proxy. That audit is the invariant to
2479
+ // preserve when rebuilding bundles or reordering vm_m_initialize.
1080
2480
  //
1081
- // 2. vm_m_loadPolyfillBytecode — bytecode from a Ruby String, copied to a
1082
- // malloc'd buffer first so the buffer survives a GC compact. This
2481
+ // 2. vm_m_loadPolyfillBytecode — bytecode from a Ruby String, copied to
2482
+ // a malloc'd buffer first so the buffer survives a GC compact. This
1083
2483
  // bytecode is arbitrary (registered by companion gems), so no audit
1084
2484
  // applies: the caller gates the release on can_eval_gvl_free() to
1085
2485
  // bail whenever a direct-rb_funcall bridge (File proxy, crypto, …)
1086
2486
  // is installed; console.log is covered by js_quickjsrb_log's
1087
2487
  // gvl_released_js re-acquire either way.
1088
2488
  //
1089
- // load_polyfill_bytecode delegates to the shared GVL-release region (see
1090
- // run_gvl_release_region) so every caller inherits the re-acquire safety,
1091
- // the dispose! handshake, and interrupt-proof cleanup automatically.
2489
+ // 3. vm_m_evalBytecode — user bytecode via Runnable#run, same gate and
2490
+ // buffer copy as 2, but with the awaiting runner: js_std_await's job
2491
+ // drain is bridge-free under the gate, the same argument eval_code's
2492
+ // released path relies on.
2493
+ //
2494
+ // 4. vm_m_preloadModuleBytecode — module bytecode from a Ruby String,
2495
+ // copied like 2/3, but with the deserialize-only runner
2496
+ // (bytecode_read_job_run) and no can_eval_gvl_free gate. A preload
2497
+ // registers the module def without running any top level, so no bridge
2498
+ // can fire regardless of what's installed — the release is
2499
+ // unconditional, and safe even on a bridged VM where 2/3 fall back to
2500
+ // the GVL-held path.
2501
+ //
2502
+ // run_bytecode_release_gvl delegates to the shared GVL-release region
2503
+ // (see run_gvl_release_region) so every caller inherits the re-acquire
2504
+ // safety, the dispose! handshake, and interrupt-proof cleanup
2505
+ // automatically.
1092
2506
  struct bytecode_load_job
1093
2507
  {
1094
2508
  JSContext *ctx;
@@ -1098,8 +2512,8 @@ struct bytecode_load_job
1098
2512
  };
1099
2513
 
1100
2514
  // Shared bytecode read + eval core — pure C over JSValues, MUST NOT touch
1101
- // the Ruby VM (the polyfill release path runs it without the GVL; the
1102
- // GVL-held call sites invoke it directly).
2515
+ // the Ruby VM (the polyfill and bytecode-run release paths run it without
2516
+ // the GVL; the GVL-held call sites invoke it directly).
1103
2517
  static void *bytecode_load_job_run(void *p)
1104
2518
  {
1105
2519
  struct bytecode_load_job *job = p;
@@ -1116,16 +2530,106 @@ static void *bytecode_load_job_run(void *p)
1116
2530
  return NULL;
1117
2531
  }
1118
2532
 
1119
- // take_ownership: true when buf is malloc'd storage that the release region
1120
- // should free on every exit path (including async-interrupt unwinds); false
1121
- // when buf points at static bytecode.
1122
- static JSValue load_polyfill_bytecode(VMData *data, const uint8_t *buf, size_t buf_len, bool take_ownership)
2533
+ // Awaiting variant of the core, for vm_m_evalBytecode: js_std_await drains
2534
+ // the job queue until the eval's promise settles. Same MUST-NOT-touch-Ruby
2535
+ // constraint — on a pure VM (can_eval_gvl_free) every drained job is
2536
+ // bridge-free JS, the same argument eval_code's released path relies on.
2537
+ // js_std_await passes a non-promise — including an exception preserved by
2538
+ // the JS_ReadObject short-circuit — through untouched.
2539
+ static void *bytecode_eval_await_job_run(void *p)
2540
+ {
2541
+ struct bytecode_load_job *job = p;
2542
+ bytecode_load_job_run(job);
2543
+ job->result = quickjsrb_host_await(job->ctx, job->result);
2544
+ return NULL;
2545
+ }
2546
+
2547
+ // Deserialize-only variant, for vm_m_preloadModuleBytecode: reads a module
2548
+ // def into ctx->loaded_modules without evaluating it, so a later import can
2549
+ // link and run it. Unlike the two runners above this never reaches JS
2550
+ // execution or the job queue — JS_ReadObject only deserializes, deferring
2551
+ // import resolution to js_link_module at eval time — so its release needs no
2552
+ // can_eval_gvl_free gate: there is no top level that could reach a Ruby
2553
+ // bridge, on any VM. Same MUST-NOT-touch-Ruby constraint all the same.
2554
+ static void *bytecode_read_job_run(void *p)
2555
+ {
2556
+ struct bytecode_load_job *job = p;
2557
+ job->result = JS_ReadObject(job->ctx, job->buf, job->buf_len, JS_READ_OBJ_BYTECODE);
2558
+ return NULL;
2559
+ }
2560
+
2561
+ // job_run: bytecode_load_job_run (load only) or bytecode_eval_await_job_run
2562
+ // (load + await). take_ownership: true when buf is malloc'd storage that
2563
+ // the release region should free on every exit path (including
2564
+ // async-interrupt unwinds); false when buf points at static bytecode.
2565
+ static JSValue run_bytecode_release_gvl(VMData *data, void *(*job_run)(void *), const uint8_t *buf, size_t buf_len, bool take_ownership)
1123
2566
  {
1124
2567
  struct bytecode_load_job job = {data->context, buf, buf_len, JS_UNDEFINED};
1125
- run_gvl_release_region(data, bytecode_load_job_run, &job, &job.result, take_ownership ? (uint8_t *)buf : NULL, NULL);
2568
+ run_gvl_release_region(data, job_run, &job, &job.result, take_ownership ? (uint8_t *)buf : NULL, NULL);
1126
2569
  return job.result;
1127
2570
  }
1128
2571
 
2572
+ // Every polyfill load takes the load-only runner; naming that keeps the
2573
+ // call sites, which pass their result straight to finish_polyfill_load,
2574
+ // down to one line.
2575
+ static JSValue load_polyfill_bytecode(VMData *data, const uint8_t *buf, size_t buf_len, bool take_ownership)
2576
+ {
2577
+ return run_bytecode_release_gvl(data, bytecode_load_job_run, buf, buf_len, take_ownership);
2578
+ }
2579
+
2580
+ // Shared settle check for every polyfill load's result, bundled or
2581
+ // registered. The compiled bytecode is async-wrapped (JS_EVAL_FLAG_ASYNC),
2582
+ // so a top-level throw comes back as a rejected promise, not JS_EXCEPTION;
2583
+ // re-throwing the reason routes it through to_rb_value's standard
2584
+ // exception path (interrupted/OOM mapping, oom_poisoned latching, on_log
2585
+ // dispatch). And a top level that awaits is still pending: loads never
2586
+ // drain the job queue, so nothing past the first await has run — refuse
2587
+ // loudly instead of shipping a silently half-applied VM; polyfill top
2588
+ // levels must settle synchronously (the register_polyfill contract).
2589
+ // That refusal reuses NoAwaitError, the same class eval_code raises for
2590
+ // a promise left unawaited at the top level.
2591
+ // Frees j_result on every path.
2592
+ static void finish_polyfill_load(VMData *data, JSValue j_result)
2593
+ {
2594
+ if (JS_IsException(j_result))
2595
+ {
2596
+ to_rb_value(data->context, j_result); // raises
2597
+ return;
2598
+ }
2599
+
2600
+ JSPromiseStateEnum state = JS_PromiseState(data->context, j_result);
2601
+ if (state == JS_PROMISE_REJECTED)
2602
+ {
2603
+ JSValue j_reason = JS_PromiseResult(data->context, j_result);
2604
+ JS_FreeValue(data->context, j_result);
2605
+ JS_Throw(data->context, j_reason); // consumes j_reason
2606
+ to_rb_value(data->context, JS_EXCEPTION); // raises
2607
+ return;
2608
+ }
2609
+ if (state == JS_PROMISE_PENDING)
2610
+ {
2611
+ JS_FreeValue(data->context, j_result);
2612
+ VALUE r_msg = rb_str_new2("polyfill top level must settle synchronously: top-level await leaves the load pending and the polyfill silently half-applied");
2613
+ rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_NO_AWAIT_ERROR), rb_intern("new"), 2, r_msg, Qnil));
2614
+ }
2615
+
2616
+ JS_FreeValue(data->context, j_result);
2617
+ }
2618
+
2619
+ // Both size options are read straight into a size_t, where a negative turns
2620
+ // into SIZE_MAX rather than an error: for max_stack_size that puts
2621
+ // stack_limit above stack_top so every eval raises "stack overflow", and for
2622
+ // memory_limit it reads as an enormous budget. Neither is what the caller
2623
+ // typed, so both are refused here, by name, while the name still means
2624
+ // something to them.
2625
+ static size_t size_option(VALUE r_value, const char *name)
2626
+ {
2627
+ if (!RB_INTEGER_TYPE_P(r_value) || RTEST(rb_funcall(r_value, rb_intern("negative?"), 0)))
2628
+ rb_raise(rb_eArgError, "%s must be a non-negative Integer", name);
2629
+
2630
+ return NUM2SIZET(r_value);
2631
+ }
2632
+
1129
2633
  static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1130
2634
  {
1131
2635
  VALUE r_opts;
@@ -1145,20 +2649,51 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1145
2649
  VALUE r_timeout_msec = rb_hash_aref(r_opts, ID2SYM(rb_intern("timeout_msec")));
1146
2650
  if (NIL_P(r_timeout_msec))
1147
2651
  r_timeout_msec = UINT2NUM(100);
2652
+ VALUE r_max_pending_rejections = rb_hash_aref(r_opts, ID2SYM(rb_intern("max_pending_rejections")));
2653
+ size_t max_pending_rejections =
2654
+ NIL_P(r_max_pending_rejections) ? 1000 : size_option(r_max_pending_rejections, "max_pending_rejections");
1148
2655
 
1149
2656
  VMData *data;
1150
2657
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1151
2658
 
2659
+ // Reachable only through send, since initialize is private, but reachable:
2660
+ // everything below here writes to the runtime, so this is a JS entry point
2661
+ // in all but name and was the one place not saying so. Disposed first,
2662
+ // because JS_SetContextOpaque on the next line dereferences a context that
2663
+ // dispose! has already freed, which segfaults rather than raising. Then the
2664
+ // owner, because the same writes landing beside another thread's in-flight
2665
+ // JS_Eval is the corruption this whole file is arranged to refuse.
2666
+ check_disposed(data);
2667
+ check_js_entry_owner(data);
2668
+
1152
2669
  data->eval_time->limit_ms = (int64_t)NUM2UINT(r_timeout_msec);
2670
+ data->max_pending_rejections = max_pending_rejections > UINT32_MAX ? UINT32_MAX : (uint32_t)max_pending_rejections;
1153
2671
  JS_SetContextOpaque(data->context, data);
2672
+ // Learned here, before the first line of guest code: the constructor is read
2673
+ // off the global, and a script that reassigns or deletes Proxy would
2674
+ // otherwise choose what every reader below treats as one.
2675
+ // Once per VM. initialize is private but reachable through send, and by then
2676
+ // the guest may have replaced the constructor this reads.
2677
+ if (data->proxy_class_id == 0)
2678
+ {
2679
+ static const char probe[] = "new Proxy({}, {})";
2680
+ JSValue j_probe = JS_Eval(data->context, probe, sizeof(probe) - 1, "<probe>", JS_EVAL_TYPE_GLOBAL);
2681
+ if (JS_IsException(j_probe))
2682
+ JS_FreeValue(data->context, JS_GetException(data->context));
2683
+ else
2684
+ data->proxy_class_id = JS_GetClassID(j_probe);
2685
+ JS_FreeValue(data->context, j_probe);
2686
+ }
1154
2687
  JSRuntime *runtime = JS_GetRuntime(data->context);
1155
2688
 
1156
- JS_SetMemoryLimit(runtime, NUM2UINT(r_memory_limit));
1157
- JS_SetMaxStackSize(runtime, NUM2UINT(r_max_stack_size));
2689
+ JS_SetMemoryLimit(runtime, size_option(r_memory_limit, "memory_limit"));
2690
+ data->requested_max_stack_size = size_option(r_max_stack_size, "max_stack_size");
2691
+ JS_SetMaxStackSize(runtime, data->requested_max_stack_size);
1158
2692
 
1159
2693
  register_module_loader_funcs(data);
1160
2694
  JS_SetHostPromiseRejectionTracker(runtime, quickjsrb_promise_rejection_tracker, NULL);
1161
2695
  js_std_init_handlers(runtime);
2696
+ data->std_handlers_installed = true;
1162
2697
 
1163
2698
  JSValue j_global = JS_GetGlobalObject(data->context);
1164
2699
 
@@ -1190,26 +2725,34 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1190
2725
  quickjsrb_new_ruby_bridge(data->context, js_quickjsrb_set_timeout, "setTimeout", 2));
1191
2726
  }
1192
2727
 
2728
+ // finish_polyfill_load raises (Ruby longjmp) on a load that fails or
2729
+ // doesn't settle, so nothing holding a JSValue may stay live across the
2730
+ // loads: the free at the bottom of this function would be skipped and
2731
+ // the leaked reference pins its whole object graph past JS_FreeRuntime
2732
+ // (the teardown GC only reclaims what's internally referenced, and the
2733
+ // gc_obj_list assert is compiled out by -DNDEBUG). Drop the global here
2734
+ // and re-acquire it below.
2735
+ JS_FreeValue(data->context, j_global);
2736
+
1193
2737
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillFileId))))
1194
2738
  {
1195
- JSValue j_polyfillFileResult = load_polyfill_bytecode(data, &qjsc_polyfill_file_min, qjsc_polyfill_file_min_size, false);
1196
- JS_FreeValue(data->context, j_polyfillFileResult);
2739
+ finish_polyfill_load(data, load_polyfill_bytecode(data, &qjsc_polyfill_file_min, qjsc_polyfill_file_min_size, false));
1197
2740
 
1198
2741
  quickjsrb_init_file_proxy(data);
1199
2742
  }
1200
2743
 
1201
2744
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillEncodingId))))
1202
2745
  {
1203
- JSValue j_polyfillEncodingResult = load_polyfill_bytecode(data, &qjsc_polyfill_encoding_min, qjsc_polyfill_encoding_min_size, false);
1204
- JS_FreeValue(data->context, j_polyfillEncodingResult);
2746
+ finish_polyfill_load(data, load_polyfill_bytecode(data, &qjsc_polyfill_encoding_min, qjsc_polyfill_encoding_min_size, false));
1205
2747
  }
1206
2748
 
1207
2749
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillUrlId))))
1208
2750
  {
1209
- JSValue j_polyfillUrlResult = load_polyfill_bytecode(data, &qjsc_polyfill_url_min, qjsc_polyfill_url_min_size, false);
1210
- JS_FreeValue(data->context, j_polyfillUrlResult);
2751
+ finish_polyfill_load(data, load_polyfill_bytecode(data, &qjsc_polyfill_url_min, qjsc_polyfill_url_min_size, false));
1211
2752
  }
1212
2753
 
2754
+ j_global = JS_GetGlobalObject(data->context);
2755
+
1213
2756
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillCryptoId))))
1214
2757
  {
1215
2758
  quickjsrb_init_crypto(data->context, j_global);
@@ -1218,7 +2761,7 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1218
2761
  // console and the remaining host callbacks are registered below this
1219
2762
  // point. setTimeout and the File proxy above predate the GVL-released
1220
2763
  // polyfill loads only under the audit described at
1221
- // load_polyfill_bytecode — re-read it before reordering this function
2764
+ // run_bytecode_release_gvl — re-read it before reordering this function
1222
2765
  // or registering anything else above the loads.
1223
2766
  JSValue j_console = JS_NewObject(data->context);
1224
2767
  JS_SetPropertyStr(
@@ -1246,29 +2789,64 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1246
2789
  static int interrupt_handler(JSRuntime *runtime, void *opaque)
1247
2790
  {
1248
2791
  EvalTime *eval_time = opaque;
1249
- struct timespec now;
1250
- clock_gettime(CLOCK_MONOTONIC, &now);
1251
- int64_t elapsed_ms = (int64_t)(now.tv_sec - eval_time->started_at.tv_sec) * 1000
1252
- + (now.tv_nsec - eval_time->started_at.tv_nsec) / 1000000;
1253
- return elapsed_ms >= eval_time->limit_ms ? 1 : 0;
2792
+ return eval_elapsed_ms(eval_time) >= eval_time->limit_ms ? 1 : 0;
1254
2793
  }
1255
2794
 
1256
- static VALUE to_rb_return_value(JSContext *ctx, JSValue j_val)
2795
+ // The result of an evaluation, owned for as long as it takes to convert.
2796
+ struct return_value
2797
+ {
2798
+ JSContext *ctx;
2799
+ JSValue j_val;
2800
+ };
2801
+
2802
+ static VALUE to_rb_return_value_body(VALUE r_owned)
1257
2803
  {
1258
- if (JS_VALUE_GET_NORM_TAG(j_val) == JS_TAG_OBJECT && JS_PromiseState(ctx, j_val) != -1)
2804
+ struct return_value *owned = (struct return_value *)r_owned;
2805
+ if (JS_VALUE_GET_NORM_TAG(owned->j_val) == JS_TAG_OBJECT && JS_PromiseState(owned->ctx, owned->j_val) != -1)
1259
2806
  {
1260
- JS_FreeValue(ctx, j_val);
1261
2807
  VALUE r_error_message = rb_str_new2("An unawaited Promise was returned to the top-level");
1262
2808
  rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_NO_AWAIT_ERROR), rb_intern("new"), 2, r_error_message, Qnil));
1263
2809
  return Qnil;
1264
2810
  }
1265
- VALUE result = to_rb_value(ctx, j_val);
1266
- JS_FreeValue(ctx, j_val);
1267
- return result;
2811
+ return to_rb_value(owned->ctx, owned->j_val);
2812
+ }
2813
+
2814
+ static VALUE to_rb_return_value_release(VALUE r_owned)
2815
+ {
2816
+ struct return_value *owned = (struct return_value *)r_owned;
2817
+ JS_FreeValue(owned->ctx, owned->j_val);
2818
+ return Qnil;
2819
+ }
2820
+
2821
+ // Converting a result runs guest JS — getters, toJSON — and raises on values
2822
+ // with no Ruby equivalent, so the reference has to outlive every exit rather
2823
+ // than being freed after a conversion that may never return. A nested Promise
2824
+ // is the reachable case: the walk raises, and without this the whole graph the
2825
+ // result holds is retained for the life of the VM.
2826
+ static VALUE to_rb_return_value(JSContext *ctx, JSValue j_val)
2827
+ {
2828
+ struct return_value owned = {ctx, j_val};
2829
+ return rb_ensure(to_rb_return_value_body, (VALUE)&owned, to_rb_return_value_release, (VALUE)&owned);
1268
2830
  }
1269
2831
 
1270
- static void check_oom_poisoned(VMData *data)
2832
+ static void check_vm_poisoned(VMData *data)
1271
2833
  {
2834
+ if (!NIL_P(data->r_registrar_error))
2835
+ {
2836
+ // Handed back as itself: a Timeout::Error the host asked for is theirs, and
2837
+ // it was only held this long because raising it where it landed would have
2838
+ // unwound through QuickJS.
2839
+ VALUE r_error = data->r_registrar_error;
2840
+ data->r_registrar_error = Qnil;
2841
+ rb_exc_raise(r_error);
2842
+ }
2843
+
2844
+ if (data->handle_source_broken)
2845
+ {
2846
+ VALUE r_msg = rb_str_new2("VM is poisoned: SecureRandom.random_number stopped answering with distinct usable integers, so an object could not be given a handle a guest cannot guess. A new VM will refuse in the same way, so fix the source rather than recycling: it is usually a test double still in place.");
2847
+ rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"), 2, r_msg, Qnil));
2848
+ }
2849
+
1272
2850
  if (data->oom_poisoned)
1273
2851
  {
1274
2852
  VALUE r_msg = rb_str_new2("VM is poisoned: a previous evaluation hit out-of-memory; further evaluation may segfault. Recreate the Quickjs::VM.");
@@ -1308,6 +2886,7 @@ static void arm_eval_timer(VMData *data)
1308
2886
  {
1309
2887
  clock_gettime(CLOCK_MONOTONIC, &data->eval_time->started_at);
1310
2888
  JS_SetInterruptHandler(JS_GetRuntime(data->context), interrupt_handler, data->eval_time);
2889
+ data->eval_timer_armed = true;
1311
2890
  }
1312
2891
 
1313
2892
  // Pure-path predicate: true when no JS→Ruby bridge can fire during eval
@@ -1357,7 +2936,7 @@ static void *eval_code_job_run(void *p)
1357
2936
  JSValue j_codeResult = JS_Eval(job->ctx, job->code, job->code_len, job->filename, eval_flags);
1358
2937
  if (job->async_mode)
1359
2938
  {
1360
- JSValue j_awaitedResult = js_std_await(job->ctx, j_codeResult); // frees j_codeResult
2939
+ JSValue j_awaitedResult = quickjsrb_host_await(job->ctx, j_codeResult); // frees j_codeResult
1361
2940
  job->result = JS_GetPropertyStr(job->ctx, j_awaitedResult, "value");
1362
2941
  JS_FreeValue(job->ctx, j_awaitedResult);
1363
2942
  }
@@ -1368,6 +2947,79 @@ static void *eval_code_job_run(void *p)
1368
2947
  return NULL;
1369
2948
  }
1370
2949
 
2950
+ // Opens a JS entry on this VM, or refuses if another thread already has one
2951
+ // open. README's "one VM, one thread at a time" rule stops being advisory
2952
+ // here: QuickJS contexts have no internal locking, so two threads inside
2953
+ // JS_Eval on the same context corrupt the heap, and since eval can release
2954
+ // the GVL they really do run at the same time rather than interleaving by
2955
+ // accident.
2956
+ //
2957
+ // A mutex would be the wrong instrument twice over. Held across the release
2958
+ // it deadlocks — the unlock lives after rb_thread_call_without_gvl
2959
+ // re-acquires the GVL, so a second thread blocks in pthread_mutex_lock
2960
+ // while holding the GVL and the first can never get back in to release it.
2961
+ // And a plain mutex self-deadlocks on the nesting this codebase relies on,
2962
+ // where an on_log listener inside a released region re-enters the same VM
2963
+ // on the same thread. Comparing the owner has neither problem: it lets
2964
+ // nesting through by construction and needs no lock at all.
2965
+ // Split from the raise so callers holding malloc'd state can test first and
2966
+ // clean up before unwinding (run_gvl_release_region owns input buffers by
2967
+ // the time it asks).
2968
+ static bool js_entry_owned_elsewhere(VMData *data)
2969
+ {
2970
+ return data->evals_in_flight > 0 && data->owner_thread != rb_thread_current();
2971
+ }
2972
+
2973
+ static void refuse_cross_thread_entry(VMData *data)
2974
+ {
2975
+ rb_raise(rb_eThreadError,
2976
+ "cannot use a Quickjs::VM from two threads at once; it is already evaluating on %+" PRIsVALUE,
2977
+ data->owner_thread);
2978
+ }
2979
+
2980
+ // Entry-point guard, sitting next to check_disposed. enter_js_entry alone is
2981
+ // not enough: several entry points read or mutate runtime state before they
2982
+ // reach a counted region — JS_IsJobPending in drain_jobs!, arm_eval_timer
2983
+ // almost everywhere, JS_SetModuleLoaderFunc2 in compile_module — and those
2984
+ // touches are already unsafe against another thread's in-flight JS.
2985
+ static void check_js_entry_owner(VMData *data)
2986
+ {
2987
+ if (js_entry_owned_elsewhere(data))
2988
+ refuse_cross_thread_entry(data);
2989
+ }
2990
+
2991
+ static void enter_js_entry(VMData *data)
2992
+ {
2993
+ if (js_entry_owned_elsewhere(data))
2994
+ refuse_cross_thread_entry(data);
2995
+
2996
+ // After the refusal and before the count, in that order for two reasons.
2997
+ // The re-base mutates rt->stack_top, which another thread's in-flight JS is
2998
+ // checking itself against, so a caller about to be turned away must not have
2999
+ // touched it. And rebase_stack_limit reads evals_in_flight to recognise the
3000
+ // outermost entry, which the increment below is about to spoil.
3001
+ rebase_stack_limit(data);
3002
+
3003
+ data->owner_thread = rb_thread_current();
3004
+ data->evals_in_flight++;
3005
+ }
3006
+
3007
+ // Balances enter_js_entry. Clearing the owner only as the outermost entry
3008
+ // closes is what lets a VM be handed between threads sequentially, which
3009
+ // the README allows and pool warmers depend on.
3010
+ //
3011
+ // eval_timer_armed deliberately does not drop here. An evaluation is two
3012
+ // counted regions, the run and then the conversion of its result, and the
3013
+ // budget spans both: a getter that outlives it while the result is being
3014
+ // rendered has to be reported, and the count is already back at zero by then.
3015
+ // The flag therefore means "some entry armed this clock", and each entry point
3016
+ // that renders is responsible for arming above the work it renders.
3017
+ static void leave_js_entry(VMData *data)
3018
+ {
3019
+ if (--data->evals_in_flight == 0)
3020
+ data->owner_thread = Qnil;
3021
+ }
3022
+
1371
3023
  // A GVL-release region — the one place that owns the handshake required to
1372
3024
  // run a pure-C QuickJS job with the GVL released: the save/restore of
1373
3025
  // gvl_released_js (restore, not clear, so a region nested through an
@@ -1404,19 +3056,154 @@ static VALUE gvl_release_region_cleanup(VALUE p)
1404
3056
  struct gvl_release_region *region = (struct gvl_release_region *)p;
1405
3057
  VMData *data = region->data;
1406
3058
  data->gvl_released_js = region->prev_gvl_released;
1407
- data->evals_in_flight--;
3059
+ leave_js_entry(data);
1408
3060
  data->gvl_release_regions--;
1409
3061
  free(region->owned_bufs[0]);
1410
3062
  free(region->owned_bufs[1]);
1411
3063
  // Frees the result when the interrupt landed after the job ran but
1412
- // before the run function marked completion. An interrupt during the
1413
- // caller's subsequent Ruby conversion can still leak the result — the
1414
- // same (accepted) exposure every GVL-held path has always had.
3064
+ // before the run function marked completion. Once the result reaches the
3065
+ // caller it is to_rb_return_value's to own, including when an interrupt
3066
+ // lands during the conversion.
1415
3067
  if (!region->completed)
1416
3068
  JS_FreeValue(data->context, *region->j_result);
1417
3069
  return Qnil;
1418
3070
  }
1419
3071
 
3072
+ // The bounds of the calling thread's stack, probed once and remembered. They
3073
+ // do not move for the life of the thread, and the probe is far too expensive
3074
+ // to repeat: on glibc the main thread takes the path that opens
3075
+ // /proc/self/maps and parses it line by line looking for the vma holding
3076
+ // __libc_stack_end, which in a Ruby process with its many mappings measured
3077
+ // at ~68us. That was landing on every outermost entry, against a bare
3078
+ // eval_code('1+1') of ~2.5us.
3079
+ //
3080
+ // Probing once and answering from the cache also settles the fiber case for
3081
+ // free. A Ruby Fiber (and Enumerator, and every fiber scheduler) runs on its
3082
+ // own mmap'd stack outside these bounds, so a frame pointer that falls
3083
+ // outside them is not on this thread's stack and there is nothing to
3084
+ // measure; that answer needs no syscall once the bounds are known.
3085
+ struct thread_stack_bounds
3086
+ {
3087
+ uintptr_t low;
3088
+ uintptr_t high;
3089
+ bool probed;
3090
+ };
3091
+
3092
+ static void probe_thread_stack_bounds(struct thread_stack_bounds *bounds)
3093
+ {
3094
+ bounds->probed = true;
3095
+ bounds->low = 0;
3096
+ bounds->high = 0;
3097
+ #if defined(__APPLE__)
3098
+ pthread_t self = pthread_self();
3099
+ // Apple reports the address one past the top of the stack, growing down.
3100
+ uintptr_t high = (uintptr_t)pthread_get_stackaddr_np(self);
3101
+ size_t size = pthread_get_stacksize_np(self);
3102
+ if (high == 0 || size == 0 || size > high)
3103
+ return;
3104
+ bounds->low = high - size;
3105
+ bounds->high = high;
3106
+ #elif defined(__linux__)
3107
+ pthread_attr_t attr;
3108
+ if (pthread_getattr_np(pthread_self(), &attr) != 0)
3109
+ return;
3110
+ void *base;
3111
+ size_t size;
3112
+ int rc = pthread_attr_getstack(&attr, &base, &size);
3113
+ pthread_attr_destroy(&attr);
3114
+ if (rc != 0 || base == NULL || size == 0)
3115
+ return;
3116
+ bounds->low = (uintptr_t)base;
3117
+ bounds->high = bounds->low + size;
3118
+ #endif
3119
+ }
3120
+
3121
+ // How much stack the calling thread still has below this frame, or 0 when
3122
+ // that cannot be answered: the platform has neither call, the probe failed,
3123
+ // or the frame is not on this thread's stack at all. rebase_stack_limit
3124
+ // reads 0 as "leave this VM alone".
3125
+ static size_t current_thread_stack_headroom(void)
3126
+ {
3127
+ static __thread struct thread_stack_bounds bounds;
3128
+ if (!bounds.probed)
3129
+ probe_thread_stack_bounds(&bounds);
3130
+ if (bounds.high == 0)
3131
+ return 0;
3132
+
3133
+ // Both bounds, not just the lower one. Where the allocator puts a fiber's
3134
+ // stack relative to the thread's is not ours to predict: below it, sp <= low
3135
+ // and a lower-bound check already answered 0; above it, sp - low measures
3136
+ // across unrelated mappings and reports headroom that is not there.
3137
+ uintptr_t sp = (uintptr_t)__builtin_frame_address(0);
3138
+ return (sp > bounds.low && sp < bounds.high) ? (size_t)(sp - bounds.low) : 0;
3139
+ }
3140
+
3141
+ // Room left for QuickJS to report the overflow and for Ruby to unwind through
3142
+ // the bridge frames above it once it does. The check itself only compares the
3143
+ // frame pointer, so the margin has to cover everything that still has to run
3144
+ // after it fires.
3145
+ #define QUICKJSRB_STACK_MARGIN (256 * 1024)
3146
+
3147
+ // QuickJS latches rt->stack_top from whichever thread called JS_NewRuntime and
3148
+ // never revisits it, so rt->stack_limit (stack_top - stack_size) keeps pointing
3149
+ // into that thread's stack for the life of the VM. Evaluate from a thread whose
3150
+ // stack sits below that limit and js_check_stack_overflow trips on its very
3151
+ // first check, reporting "stack overflow" on code as trivial as 1 + 1.
3152
+ //
3153
+ // Whether it fires is luck of where the OS put the two stacks: on macOS the gap
3154
+ // stays under the 4MB default, so the handoff README.md:472 promises appears to
3155
+ // work, while on Linux every cross-thread eval raises.
3156
+ //
3157
+ // Re-basing alone would trade that for something worse. A Ruby thread's machine
3158
+ // stack is a fraction of the main thread's, so a 4MB budget re-based onto one
3159
+ // outlives the stack it is measuring: Ruby's guard page is reached first and the
3160
+ // eval dies with SystemStackError instead of QuickJS raising. Base and budget
3161
+ // therefore move together, or not at all.
3162
+ //
3163
+ // Not at all is a real case. pthread reports the *thread's* stack, and a Ruby
3164
+ // Fiber runs on its own mmap'd one outside those bounds, as do Enumerator and
3165
+ // every fiber scheduler; the query returns 0 there, and on platforms with
3166
+ // neither call it always does. Re-basing without being able to clamp is the
3167
+ // worst of the three outcomes, so an unmeasurable stack leaves the VM exactly
3168
+ // as it was: still latched to its creating thread, still the pre-existing
3169
+ // behaviour, and no new way to run off the end of a stack.
3170
+ //
3171
+ // Outermost entry only: a nested one (a bridge re-entering its own VM, an
3172
+ // on_log listener evaluating) sits deeper on the same stack, and re-basing
3173
+ // there would hand it a fresh full budget, removing the guard exactly where
3174
+ // runaway recursion is what needs catching.
3175
+ static void rebase_stack_limit(VMData *data)
3176
+ {
3177
+ if (data->evals_in_flight != 0)
3178
+ return;
3179
+
3180
+ size_t headroom = current_thread_stack_headroom();
3181
+
3182
+ // A headroom under the margin is not a small budget, it is an unusable
3183
+ // answer: there would be no room left to report the overflow with. musl
3184
+ // makes that the normal case rather than a corner one, because its
3185
+ // main-thread pthread_attr_getstack reports only the currently mapped part
3186
+ // of the stack rather than what RLIMIT_STACK allows, so early on the answer
3187
+ // is a few pages. Clamping to what is left there set stack_limit one byte
3188
+ // below stack_top and every eval raised "stack overflow", 1 + 1 included.
3189
+ //
3190
+ // So this joins the zero case: an answer we cannot use leaves the VM exactly
3191
+ // as it was, latched to its creating thread. Cross-thread handoff stays
3192
+ // broken on such a platform, which is the pre-existing behaviour, rather
3193
+ // than the platform breaking outright.
3194
+ if (headroom <= QUICKJSRB_STACK_MARGIN)
3195
+ return;
3196
+
3197
+ JSRuntime *runtime = JS_GetRuntime(data->context);
3198
+ JS_UpdateStackTop(runtime);
3199
+
3200
+ if (data->requested_max_stack_size == 0)
3201
+ return; // caller asked for no limit; honour it
3202
+
3203
+ size_t usable = headroom - QUICKJSRB_STACK_MARGIN;
3204
+ JS_SetMaxStackSize(runtime, usable < data->requested_max_stack_size ? usable : data->requested_max_stack_size);
3205
+ }
3206
+
1420
3207
  // Run job_run(job) with the GVL released. owned_buf0/1 are malloc'd
1421
3208
  // buffers backing the job's inputs; ownership transfers to the region,
1422
3209
  // which frees them on every exit path — including the disposed bail-out
@@ -1425,13 +3212,17 @@ static VALUE gvl_release_region_cleanup(VALUE p)
1425
3212
  // dispose!), so re-check here: nothing between this check and the release
1426
3213
  // yields, and dispose! refuses while evals_in_flight > 0, so the two
1427
3214
  // sides can't miss each other.
3215
+
1428
3216
  static void run_gvl_release_region(VMData *data, void *(*job_run)(void *), void *job, JSValue *j_result, void *owned_buf0, void *owned_buf1)
1429
3217
  {
1430
- if (data->disposed)
3218
+ // Both refusals have to happen before the buffers are handed to the
3219
+ // region, since the rb_ensure that would free them is not armed yet.
3220
+ if (data->disposed || js_entry_owned_elsewhere(data))
1431
3221
  {
1432
3222
  free(owned_buf0);
1433
3223
  free(owned_buf1);
1434
- check_disposed(data); // raises
3224
+ check_disposed(data); // raises when disposed
3225
+ refuse_cross_thread_entry(data); // otherwise it was the owner check
1435
3226
  }
1436
3227
 
1437
3228
  struct gvl_release_region region = {
@@ -1444,7 +3235,7 @@ static void run_gvl_release_region(VMData *data, void *(*job_run)(void *), void
1444
3235
  .completed = false,
1445
3236
  };
1446
3237
 
1447
- data->evals_in_flight++;
3238
+ enter_js_entry(data); // cannot raise: the check above already passed
1448
3239
  data->gvl_release_regions++;
1449
3240
  data->gvl_released_js = true;
1450
3241
  rb_ensure(gvl_release_region_run, (VALUE)&region, gvl_release_region_cleanup, (VALUE)&region);
@@ -1466,7 +3257,7 @@ static void check_no_gvl_release_in_flight(VMData *data)
1466
3257
 
1467
3258
  static VALUE evals_in_flight_release(VALUE p)
1468
3259
  {
1469
- ((VMData *)p)->evals_in_flight--;
3260
+ leave_js_entry((VMData *)p);
1470
3261
  return Qnil;
1471
3262
  }
1472
3263
 
@@ -1487,16 +3278,100 @@ static VALUE evals_in_flight_release(VALUE p)
1487
3278
  static VALUE run_held_js_entry(VMData *data, VALUE (*body)(VALUE), VALUE arg)
1488
3279
  {
1489
3280
  check_disposed(data);
1490
- data->evals_in_flight++;
3281
+ // Raises before the ensure is armed, which is safe here: unlike the
3282
+ // release region, this path owns no malloc'd state at this point.
3283
+ enter_js_entry(data);
1491
3284
  return rb_ensure(body, arg, evals_in_flight_release, (VALUE)data);
1492
3285
  }
1493
3286
 
1494
- static VALUE eval_code_job_run_body(VALUE p)
3287
+ // Result conversion runs guest JS — getters, toJSON, a Proxy trap — and the
3288
+ // bridges that code can reach yield the GVL, so it has to be a counted JS entry
3289
+ // like every other execution. Without it a dispose! from a getter is granted
3290
+ // while the walk is still reading the context it frees (#81). The entry closes
3291
+ // before the conversion's own value is handed back, which is why these wrap the
3292
+ // conversion rather than the whole tail.
3293
+ static VALUE to_rb_return_value_held_body(VALUE r_owned)
3294
+ {
3295
+ struct return_value *owned = (struct return_value *)r_owned;
3296
+ return to_rb_return_value(owned->ctx, owned->j_val);
3297
+ }
3298
+
3299
+ // The owning ensure is armed inside the entry, not around it, so that the
3300
+ // result is freed while the entry is still held — freeing it outside would
3301
+ // reopen the window this exists to close. That leaves j_val unowned if
3302
+ // run_held_js_entry itself raises, which it cannot here: these tails run on
3303
+ // the thread that produced the value, with no GVL yield since the producing
3304
+ // entry closed, so neither `disposed` nor `owner_thread` can have changed.
3305
+ static VALUE to_rb_return_value_held(VMData *data, JSValue j_val)
3306
+ {
3307
+ struct return_value owned = {data->context, j_val};
3308
+ return run_held_js_entry(data, to_rb_return_value_held_body, (VALUE)&owned);
3309
+ }
3310
+
3311
+ static VALUE js_exception_held_body(VALUE r_owned)
3312
+ {
3313
+ struct return_value *owned = (struct return_value *)r_owned;
3314
+ return to_rb_value(owned->ctx, owned->j_val);
3315
+ }
3316
+
3317
+ // For the tails whose value is the JS_EXCEPTION sentinel: converting it pulls
3318
+ // the pending exception and raises. Unlike to_rb_return_value_held this does
3319
+ // not own its argument, which is why it takes the sentinel rather than a
3320
+ // JSValue in general — the sentinel carries no reference to release.
3321
+ static VALUE raise_from_js_exception_held(VMData *data)
3322
+ {
3323
+ struct return_value owned = {data->context, JS_EXCEPTION};
3324
+ return run_held_js_entry(data, js_exception_held_body, (VALUE)&owned);
3325
+ }
3326
+
3327
+ // run_held_js_entry that ends the microtask checkpoint after body, raise or not.
3328
+ struct held_checkpoint_call
3329
+ {
3330
+ VMData *data;
3331
+ VALUE (*body)(VALUE);
3332
+ VALUE arg;
3333
+ };
3334
+
3335
+ static VALUE held_checkpoint_end(VALUE p)
1495
3336
  {
1496
- eval_code_job_run((struct eval_code_job *)p);
3337
+ VMData *data = ((struct held_checkpoint_call *)p)->data;
3338
+ // Only the outermost entry: a nested one returns while outer JS is still
3339
+ // running, and this also keeps the handler from re-entering the checkpoint.
3340
+ if (data->evals_in_flight == 1)
3341
+ quickjsrb_end_microtask_checkpoint(data);
1497
3342
  return Qnil;
1498
3343
  }
1499
3344
 
3345
+ static VALUE held_checkpoint_body(VALUE p)
3346
+ {
3347
+ struct held_checkpoint_call *call = (struct held_checkpoint_call *)p;
3348
+ return rb_ensure(call->body, call->arg, held_checkpoint_end, p);
3349
+ }
3350
+
3351
+ static VALUE run_held_js_checkpoint_entry(VMData *data, VALUE (*body)(VALUE), VALUE arg)
3352
+ {
3353
+ struct held_checkpoint_call call = {data, body, arg};
3354
+ return run_held_js_entry(data, held_checkpoint_body, (VALUE)&call);
3355
+ }
3356
+
3357
+ static VALUE eval_code_job_run_body(VALUE p)
3358
+ {
3359
+ struct eval_code_job *job = (struct eval_code_job *)p;
3360
+ eval_code_job_run(job);
3361
+ return to_rb_return_value(job->ctx, job->result);
3362
+ }
3363
+
3364
+ // Unwraps js_std_await's {value, done}, or raises the JS exception.
3365
+ static VALUE bytecode_eval_result_to_rb(JSContext *ctx, JSValue j_result)
3366
+ {
3367
+ if (JS_IsException(j_result))
3368
+ return to_rb_value(ctx, j_result); // raises
3369
+
3370
+ JSValue j_returnedValue = JS_GetPropertyStr(ctx, j_result, "value");
3371
+ JS_FreeValue(ctx, j_result);
3372
+ return to_rb_return_value(ctx, j_returnedValue);
3373
+ }
3374
+
1500
3375
  // rb_ensure bodies over the shared bytecode core, for the GVL-held call
1501
3376
  // sites: vm_m_loadPolyfillBytecode's held fallback loads without awaiting
1502
3377
  // (matching its release path), vm_m_evalBytecode awaits the result. These
@@ -1512,11 +3387,8 @@ static VALUE bytecode_load_body(VALUE p)
1512
3387
  static VALUE bytecode_eval_await_body(VALUE p)
1513
3388
  {
1514
3389
  struct bytecode_load_job *job = (struct bytecode_load_job *)p;
1515
- bytecode_load_job_run(job);
1516
- // js_std_await passes a non-promise — including an exception preserved by
1517
- // the JS_ReadObject short-circuit — through untouched.
1518
- job->result = js_std_await(job->ctx, job->result);
1519
- return Qnil;
3390
+ bytecode_eval_await_job_run(job);
3391
+ return bytecode_eval_result_to_rb(job->ctx, job->result);
1520
3392
  }
1521
3393
 
1522
3394
  // Copy a Ruby String to a malloc'd buffer that outlives a GVL release —
@@ -1567,86 +3439,533 @@ static VALUE eval_code_release_gvl(VMData *data, VALUE r_code, const char *filen
1567
3439
  };
1568
3440
  run_gvl_release_region(data, eval_code_job_run, &job, &job.result, code_buf, filename_buf);
1569
3441
 
1570
- return to_rb_return_value(data->context, job.result);
3442
+ return to_rb_return_value_held(data, job.result);
3443
+ }
3444
+
3445
+ static VALUE vm_m_evalCode(int argc, VALUE *argv, VALUE r_self)
3446
+ {
3447
+ VMData *data;
3448
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
3449
+
3450
+ check_disposed(data);
3451
+ check_vm_poisoned(data);
3452
+ check_js_entry_owner(data);
3453
+
3454
+ VALUE r_code, r_opts;
3455
+ rb_scan_args(argc, argv, "1:", &r_code, &r_opts);
3456
+ const char *filename = parse_code_and_filename(r_code, r_opts);
3457
+
3458
+ bool async_mode = true;
3459
+ if (!NIL_P(r_opts))
3460
+ {
3461
+ VALUE r_async = rb_hash_aref(r_opts, ID2SYM(rb_intern("async")));
3462
+ if (r_async == Qfalse)
3463
+ async_mode = false;
3464
+ }
3465
+
3466
+ // Argument parsing allocates, so it can yield the GVL to a concurrent
3467
+ // dispose! that leaves data->context dangling — and arm_eval_timer
3468
+ // dereferences the runtime, which puts it ahead of both the release
3469
+ // region's own re-check and run_held_js_entry's. Nothing between here and
3470
+ // the release yields (StringValue can't coerce: parse_code_and_filename
3471
+ // already refused a non-String), and dispose! refuses once either of those
3472
+ // has counted us, so re-checking here closes the window on both paths.
3473
+ check_disposed(data);
3474
+ // Ownership needs the same re-check as disposal, and for the same reason:
3475
+ // the parsing above yields, so another thread can have taken the VM since
3476
+ // the check at entry. arm_eval_timer writes the shared eval_time, so
3477
+ // without this it would reset the budget of the eval that thread is
3478
+ // already running — the counted regions below refuse, but only after.
3479
+ check_js_entry_owner(data);
3480
+ arm_eval_timer(data);
3481
+
3482
+ StringValue(r_code);
3483
+
3484
+ if (can_eval_gvl_free(data))
3485
+ return eval_code_release_gvl(data, r_code, filename, async_mode);
3486
+
3487
+ // Bridged path: a JS→Ruby bridge (define_function / module loader /
3488
+ // setTimeout / File / crypto) may fire mid-eval, so keep the GVL held and
3489
+ // run the shared eval core directly. With the GVL held there's no
3490
+ // compaction risk, so RSTRING_PTR is usable without a malloc'd copy.
3491
+ struct eval_code_job job = {
3492
+ .ctx = data->context,
3493
+ .code = RSTRING_PTR(r_code),
3494
+ .code_len = (size_t)RSTRING_LEN(r_code),
3495
+ .filename = filename,
3496
+ .async_mode = async_mode,
3497
+ .result = JS_UNDEFINED,
3498
+ };
3499
+ // No checkpoint on the GVL-free path: it is not taken with a handler set.
3500
+ return run_held_js_checkpoint_entry(data, eval_code_job_run_body, (VALUE)&job);
3501
+ }
3502
+
3503
+ struct compile_job
3504
+ {
3505
+ JSContext *ctx;
3506
+ const char *code;
3507
+ size_t code_len;
3508
+ const char *filename;
3509
+ JSValue result;
3510
+ };
3511
+
3512
+ // Parses to a function object and stops there. Like the preload read, and
3513
+ // unlike the eval core above, this runs no JS at all, so its release needs
3514
+ // no can_eval_gvl_free gate — there is no top level that could reach a Ruby
3515
+ // bridge, on any VM. JS_EVAL_TYPE_GLOBAL is what carries that argument:
3516
+ // __JS_EvalInternal only resolves imports for the JSModuleDef it builds
3517
+ // under JS_EVAL_TYPE_MODULE, so no module loader can fire here either. A
3518
+ // module compile is a different story and gets no such freedom. Same
3519
+ // MUST-NOT-touch-Ruby constraint as every other released job.
3520
+ static void *compile_job_run(void *p)
3521
+ {
3522
+ struct compile_job *job = p;
3523
+ job->result = JS_Eval(job->ctx, job->code, job->code_len, job->filename,
3524
+ JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC | JS_EVAL_FLAG_COMPILE_ONLY);
3525
+ return NULL;
3526
+ }
3527
+
3528
+ // Inputs are copied because RSTRING_PTR can be invalidated by GC compaction
3529
+ // while we're released, same as eval_code_release_gvl.
3530
+ static JSValue compile_release_gvl(VMData *data, VALUE r_code, const char *filename)
3531
+ {
3532
+ size_t code_len;
3533
+ char *code_buf = copy_rstring_to_owned_buffer(r_code, &code_len, true);
3534
+
3535
+ char *filename_buf = strdup(filename);
3536
+ if (filename_buf == NULL)
3537
+ {
3538
+ free(code_buf);
3539
+ rb_raise(rb_eNoMemError, "failed to allocate compile filename buffer");
3540
+ }
3541
+
3542
+ struct compile_job job = {
3543
+ .ctx = data->context,
3544
+ .code = code_buf,
3545
+ .code_len = code_len,
3546
+ .filename = filename_buf,
3547
+ .result = JS_UNDEFINED,
3548
+ };
3549
+ run_gvl_release_region(data, compile_job_run, &job, &job.result, code_buf, filename_buf);
3550
+
3551
+ return job.result;
3552
+ }
3553
+
3554
+ struct bytecode_serialize_job
3555
+ {
3556
+ VMData *data;
3557
+ JSValue j_compiled; // owned here; see compiled_to_bytecode_string for the rule
3558
+ uint8_t *out_buf;
3559
+ const char *failure_message;
3560
+ };
3561
+
3562
+ static VALUE bytecode_serialize_body(VALUE p)
3563
+ {
3564
+ struct bytecode_serialize_job *job = (struct bytecode_serialize_job *)p;
3565
+
3566
+ size_t out_len;
3567
+ job->out_buf = JS_WriteObject(job->data->context, &out_len, job->j_compiled, JS_WRITE_OBJ_BYTECODE);
3568
+ if (job->out_buf == NULL)
3569
+ {
3570
+ VALUE r_msg = rb_str_new2(job->failure_message);
3571
+ rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"), 2, r_msg, Qnil));
3572
+ }
3573
+
3574
+ VALUE r_bytecode = rb_str_new((const char *)job->out_buf, (long)out_len);
3575
+ rb_enc_associate(r_bytecode, rb_ascii8bit_encoding());
3576
+ return rb_obj_freeze(r_bytecode);
3577
+ }
3578
+
3579
+ static VALUE bytecode_serialize_cleanup(VALUE p)
3580
+ {
3581
+ struct bytecode_serialize_job *job = (struct bytecode_serialize_job *)p;
3582
+
3583
+ JS_FreeValue(job->data->context, job->j_compiled);
3584
+ if (job->out_buf != NULL)
3585
+ js_free(job->data->context, job->out_buf);
3586
+ return Qnil;
3587
+ }
3588
+
3589
+ static VALUE bytecode_serialize_held(VALUE p)
3590
+ {
3591
+ return rb_ensure(bytecode_serialize_body, p, bytecode_serialize_cleanup, p);
3592
+ }
3593
+
3594
+ // Serializes a compiled function or module into a frozen ASCII-8BIT String.
3595
+ // Shared by both compile entry points, and a GVL-held JS entry rather than
3596
+ // plain inline code for two reasons: rb_str_new allocates, which is a thread
3597
+ // switch point, so without evals_in_flight elevated a concurrent dispose!
3598
+ // could free the context between JS_WriteObject and the js_free below; and
3599
+ // the blob JS_WriteObject hands back is js_malloc'd, so it has to go home
3600
+ // through js_free to keep the runtime's memory accounting straight — the
3601
+ // ensure gets it there however the body exits, including an async interrupt
3602
+ // landing in rb_str_new.
3603
+ //
3604
+ // Ownership of j_compiled transfers here in full, but "consumes it" is not
3605
+ // quite the whole rule, because run_held_js_entry's disposed check raises
3606
+ // ahead of the rb_ensure that does the freeing. That path frees nothing, and
3607
+ // must not: dispose! sets disposed before handing the teardown to
3608
+ // JS_FreeRuntime, so by the time the check fires j_compiled's storage is
3609
+ // either already reclaimed or being reclaimed concurrently with the GVL
3610
+ // released, and a JS_FreeValue aimed at it would be a use-after-free rather
3611
+ // than a cleanup. Nothing leaks either way — the runtime took it. So: freed
3612
+ // through the context on every exit while the VM is alive, abandoned to the
3613
+ // teardown once it is not. Unreachable today in any case, since neither
3614
+ // caller yields the GVL between its compile and this call.
3615
+ static VALUE compiled_to_bytecode_string(VMData *data, JSValue j_compiled, const char *failure_message)
3616
+ {
3617
+ struct bytecode_serialize_job job = {
3618
+ .data = data,
3619
+ .j_compiled = j_compiled,
3620
+ .out_buf = NULL,
3621
+ .failure_message = failure_message,
3622
+ };
3623
+ return run_held_js_entry(data, bytecode_serialize_held, (VALUE)&job);
3624
+ }
3625
+
3626
+ struct js_exception_job
3627
+ {
3628
+ VMData *data;
3629
+ JSValue j_exception;
3630
+ };
3631
+
3632
+ static VALUE js_exception_body(VALUE p)
3633
+ {
3634
+ struct js_exception_job *job = (struct js_exception_job *)p;
3635
+ return to_rb_value(job->data->context, job->j_exception); // raises
3636
+ }
3637
+
3638
+ // Turns a thrown JSValue into the Ruby exception it raises, as a counted JS
3639
+ // entry rather than a bare call. Converting an error is JS execution: it
3640
+ // reads name, message and stack off the thrown object, and any of the three
3641
+ // can be an accessor inherited from a prototype the VM's own code has
3642
+ // replaced. So a parse failure on a VM where something did
3643
+ //
3644
+ // Object.defineProperty(SyntaxError.prototype, 'name', { get: () => boom() })
3645
+ //
3646
+ // runs boom() during conversion — and with evals_in_flight back at zero after
3647
+ // the release region, a dispose! from that bridge was granted and the rest of
3648
+ // the conversion ran against a freed context. Reproducible SIGSEGV.
3649
+ //
3650
+ // The parse half of a compile is counted and the serialize half is counted;
3651
+ // this is the third tail out of the same function, and it was the one left
3652
+ // bare.
3653
+ static VALUE js_exception_to_rb(VMData *data, JSValue j_exception)
3654
+ {
3655
+ struct js_exception_job job = {
3656
+ .data = data,
3657
+ .j_exception = j_exception,
3658
+ };
3659
+ return run_held_js_entry(data, js_exception_body, (VALUE)&job);
1571
3660
  }
1572
3661
 
1573
- static VALUE vm_m_evalCode(int argc, VALUE *argv, VALUE r_self)
3662
+ static VALUE vm_m_compile(int argc, VALUE *argv, VALUE r_self)
1574
3663
  {
1575
3664
  VMData *data;
1576
3665
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1577
3666
 
1578
3667
  check_disposed(data);
1579
- check_oom_poisoned(data);
3668
+ check_vm_poisoned(data);
3669
+ check_js_entry_owner(data);
1580
3670
 
1581
3671
  VALUE r_code, r_opts;
1582
3672
  rb_scan_args(argc, argv, "1:", &r_code, &r_opts);
1583
3673
  const char *filename = parse_code_and_filename(r_code, r_opts);
1584
3674
 
1585
- bool async_mode = true;
1586
- if (!NIL_P(r_opts))
3675
+ // Re-check for the same reason vm_m_evalCode does: argument parsing can
3676
+ // yield to a concurrent dispose!, and arm_eval_timer is the first thing
3677
+ // here to touch the context.
3678
+ //
3679
+ // Arming buys a compile nothing: the parser never calls js_poll_interrupts
3680
+ // — every call site is in the interpreter, for-in, instanceof or regexp
3681
+ // exec — so timeout_msec does not bound a compile, and a pathological
3682
+ // source parses for as long as it takes. Releasing the GVL at least keeps
3683
+ // that off every other Ruby thread. It is kept only so an outermost
3684
+ // compile starts from a fresh clock rather than inheriting a lapsed one.
3685
+ //
3686
+ // Nested, it is worse than useless: a compile issued from a bridge
3687
+ // callback (a define_function proc, an on_log listener) would overwrite
3688
+ // the enclosing eval's started_at on every call, so interrupt_handler
3689
+ // never sees the elapsed limit and the enclosing eval never times out at
3690
+ // all. vm_m_loadPolyfillBytecode conditions on the same counter for the
3691
+ // same reason.
3692
+ //
3693
+ // Not a file-wide invariant yet, to be clear. vm_m_evalCode,
3694
+ // vm_m_evalBytecode, call_global_function_body, vm_m_import and
3695
+ // vm_m_drainJobs all still arm unconditionally and all still defeat
3696
+ // timeout_msec the same way — call_global_function_body above its path
3697
+ // resolution, so a nested call that fails to resolve has already reset the
3698
+ // enclosing clock. Hoisting the condition into arm_eval_timer
3699
+ // would close them together, but it would also start interrupting
3700
+ // workloads that re-enter the VM from a bridge and today run unbounded —
3701
+ // a semantics change that deserves its own PR rather than a ride on this
3702
+ // one.
3703
+ check_disposed(data);
3704
+ // Same re-check as vm_m_evalCode, for the same reason: parsing yielded, so
3705
+ // the owner may have changed since the check at entry.
3706
+ check_js_entry_owner(data);
3707
+ if (data->evals_in_flight == 0)
3708
+ arm_eval_timer(data);
3709
+
3710
+ // A guaranteed no-op: parse_code_and_filename already refused a
3711
+ // non-String, so no to_str can run here and open a yield point between
3712
+ // the check above and the release below.
3713
+ StringValue(r_code);
3714
+ // Serializing stays on the GVL-held side of the region: the region frees
3715
+ // its buffers with free(), while JS_WriteObject's blob has to go back
3716
+ // through js_free, and teaching it a second kind of ownership for one
3717
+ // caller isn't worth it. Parsing dominates anyway — leaving the serialize
3718
+ // behind only pulls the two-thread ratio from ~0.5 to ~0.55.
3719
+ JSValue j_func = compile_release_gvl(data, r_code, filename);
3720
+ if (JS_IsException(j_func))
1587
3721
  {
1588
- VALUE r_async = rb_hash_aref(r_opts, ID2SYM(rb_intern("async")));
1589
- if (r_async == Qfalse)
1590
- async_mode = false;
3722
+ return js_exception_to_rb(data, j_func); // raises Ruby exception
1591
3723
  }
1592
3724
 
1593
- arm_eval_timer(data);
3725
+ return compiled_to_bytecode_string(data, j_func, "failed to serialize compiled bytecode");
3726
+ }
1594
3727
 
1595
- StringValue(r_code);
3728
+ // Stands in for the real loader while compiling a module to bytecode.
3729
+ //
3730
+ // __JS_EvalInternal runs js_resolve_module before it honors
3731
+ // JS_EVAL_FLAG_COMPILE_ONLY, so compiling `import { x } from 'dep'` tries to
3732
+ // load 'dep' — which would make a module impossible to compile without its
3733
+ // whole dependency graph on hand. Nothing about that resolution survives:
3734
+ // JS_WriteModule stores req_module_entries as the specifier written in the
3735
+ // source, never the module it resolved to, and export names aren't checked
3736
+ // until js_link_module at evaluation time. So an empty stub satisfies the
3737
+ // compile and leaves no trace in the blob; the importing VM resolves the real
3738
+ // dependency through its own loader.
3739
+ //
3740
+ // The stubs do land in the compiling context's module map, so this must only
3741
+ // ever run on a throwaway VM — otherwise a later real import of 'dep' on that
3742
+ // VM would find the empty stub.
3743
+ static JSModuleDef *quickjsrb_stub_module_loader(JSContext *ctx, const char *module_name, void *opaque, JSValueConst attributes)
3744
+ {
3745
+ static const char *empty_module = "export {};";
3746
+ JSValue j_stub = JS_Eval(ctx, empty_module, strlen(empty_module), module_name,
3747
+ JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
3748
+ if (JS_IsException(j_stub))
3749
+ return NULL;
1596
3750
 
1597
- if (can_eval_gvl_free(data))
1598
- return eval_code_release_gvl(data, r_code, filename, async_mode);
3751
+ JSModuleDef *m = JS_VALUE_GET_PTR(j_stub);
3752
+ JS_FreeValue(ctx, j_stub);
3753
+ return m;
3754
+ }
1599
3755
 
1600
- // Bridged path: a JS→Ruby bridge (define_function / module loader /
1601
- // setTimeout / File / crypto) may fire mid-eval, so keep the GVL held and
1602
- // run the shared eval core directly. With the GVL held there's no
1603
- // compaction risk, so RSTRING_PTR is usable without a malloc'd copy.
1604
- struct eval_code_job job = {
1605
- .ctx = data->context,
1606
- .code = RSTRING_PTR(r_code),
1607
- .code_len = (size_t)RSTRING_LEN(r_code),
1608
- .filename = filename,
1609
- .async_mode = async_mode,
1610
- .result = JS_UNDEFINED,
3756
+ // Compiles source as an ES module and serializes it. Unlike vm_m_compile,
3757
+ // `name` is not a debug label: js_read_module rebuilds the JSModuleDef under
3758
+ // the name baked in here, so it is the module's identity in every VM that
3759
+ // later reads this blob. Quickjs.register_module keys the registry by that
3760
+ // same string, which is what keeps the two from drifting apart.
3761
+ //
3762
+ // Stays private, and must: the stub loader below is swapped in at the
3763
+ // *runtime* level for the duration, so this assumes exclusive use of the VM.
3764
+ // Quickjs._compile_registered_module honors that by compiling on a disposable
3765
+ // VM it owns; exposing this publicly would let a compile race an import on a
3766
+ // shared VM and resolve it against stubs.
3767
+ struct compile_module_job
3768
+ {
3769
+ VMData *data;
3770
+ VALUE r_code;
3771
+ const char *name;
3772
+ };
3773
+
3774
+ static VALUE compile_module_body(VALUE p);
3775
+
3776
+ static VALUE vm_m_compileModule(VALUE r_self, VALUE r_code, VALUE r_name)
3777
+ {
3778
+ VMData *data;
3779
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
3780
+
3781
+ check_disposed(data);
3782
+ check_vm_poisoned(data);
3783
+ check_js_entry_owner(data);
3784
+ Check_Type(r_code, T_STRING);
3785
+ Check_Type(r_name, T_STRING);
3786
+
3787
+ // Hoisted above everything that touches the runtime. Check_Type already
3788
+ // ruled out a to_str, but StringValueCStr still calls rb_str_modify to
3789
+ // NUL-terminate a shared or embedded-NUL-free substring, and that
3790
+ // allocates — the same thread switch point the rest of this file treats as
3791
+ // a yield. Left where it read most naturally, inline in the JS_Eval
3792
+ // arguments, a concurrent dispose! landing there would free the runtime
3793
+ // out from under the loader swap and the eval. Coercing first puts the
3794
+ // disposed check after the last yield and before the first context access,
3795
+ // as on the other JS entry points.
3796
+ const char *name = StringValueCStr(r_name);
3797
+
3798
+ check_disposed(data);
3799
+ // Re-checked after the coercion above yields, and then immediately turned
3800
+ // into a claim by run_held_js_entry below: unlike the other compile paths,
3801
+ // the runtime work here is a full JS_Eval plus a runtime-level loader swap,
3802
+ // so a check that recorded nothing would leave both of those exposed to a
3803
+ // second thread passing the same idle check.
3804
+ check_js_entry_owner(data);
3805
+ // Guarded for the reason spelled out in vm_m_compile: only the outermost
3806
+ // JS entry point owns the timeout budget. Nesting can't happen on this
3807
+ // path today — it is private and both callers compile on a disposable VM
3808
+ // they own — but the two compile entry points arming differently would be
3809
+ // a difference with no reason behind it.
3810
+ // Armed before the claim below, not after: the condition reads the counter
3811
+ // that run_held_js_entry is about to raise, so claiming first would skip
3812
+ // the arm on every outermost compile.
3813
+ if (data->evals_in_flight == 0)
3814
+ arm_eval_timer(data);
3815
+
3816
+ struct compile_module_job job = {
3817
+ .data = data,
3818
+ .r_code = r_code,
3819
+ .name = name,
1611
3820
  };
1612
- run_held_js_entry(data, eval_code_job_run_body, (VALUE)&job);
1613
- return to_rb_return_value(data->context, job.result);
3821
+ return run_held_js_entry(data, compile_module_body, (VALUE)&job);
1614
3822
  }
1615
3823
 
1616
- static VALUE vm_m_compile(int argc, VALUE *argv, VALUE r_self)
3824
+ static VALUE compile_module_body(VALUE p)
3825
+ {
3826
+ struct compile_module_job *job = (struct compile_module_job *)p;
3827
+ VMData *data = job->data;
3828
+ VALUE r_code = job->r_code;
3829
+
3830
+ JS_SetModuleLoaderFunc2(JS_GetRuntime(data->context), NULL, quickjsrb_stub_module_loader,
3831
+ js_module_check_attributes, NULL);
3832
+ // RSTRING_PTR raw, where the released paths copy — the copy there is for GC
3833
+ // movability, and the NUL that quickjs.h:835 asks for comes free with it.
3834
+ // Held here, the raw pointer carries that NUL anyway: Ruby only shares one
3835
+ // String's buffer with another when the substring runs to the parent's end,
3836
+ // so ptr[len] is always somebody's terminator. Worth stating because the
3837
+ // lexer's comment and regexp scanners test for the NUL before the end
3838
+ // pointer, so a buffer without one would let a source ending mid-comment
3839
+ // read on into whatever follows it in memory.
3840
+ JSValue j_mod = JS_Eval(data->context, RSTRING_PTR(r_code), RSTRING_LEN(r_code), job->name,
3841
+ JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
3842
+ register_module_loader_funcs(data);
3843
+ if (JS_IsException(j_mod))
3844
+ return js_exception_to_rb(data, j_mod); // raises
3845
+
3846
+ return compiled_to_bytecode_string(data, j_mod, "failed to serialize compiled module bytecode");
3847
+ }
3848
+
3849
+ // Reads module bytecode into this context. The read registers the JSModuleDef
3850
+ // in ctx->loaded_modules but does not evaluate it: evaluation still happens on
3851
+ // first import, and js_resolve_module recurses into it from the importer, so
3852
+ // no loader hook is involved for a preloaded name.
3853
+ //
3854
+ // The name is recorded in preloaded_module_names for the normalize hook's
3855
+ // short-circuit, and returned so the caller can confirm the blob was filed
3856
+ // where it asked.
3857
+ //
3858
+ // `name` is the name the caller expects the blob to carry. Reading the same
3859
+ // module into a context twice can't be undone — js_read_module appends a
3860
+ // second JSModuleDef under the same name, js_find_loaded_module returns the
3861
+ // first one it walks past, and the orphan holds its bytecode until the
3862
+ // context is freed — so a name already preloaded here skips the read
3863
+ // entirely. That also makes this idempotent, which the caller relies on: it
3864
+ // is legitimate to preload the same module into a VM more than once.
3865
+ static VALUE vm_m_preloadModuleBytecode(VALUE r_self, VALUE r_bytecode, VALUE r_name)
1617
3866
  {
1618
3867
  VMData *data;
1619
3868
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1620
3869
 
1621
3870
  check_disposed(data);
3871
+ check_vm_poisoned(data);
3872
+ check_js_entry_owner(data);
1622
3873
 
1623
- VALUE r_code, r_opts;
1624
- rb_scan_args(argc, argv, "1:", &r_code, &r_opts);
1625
- const char *filename = parse_code_and_filename(r_code, r_opts);
1626
-
1627
- arm_eval_timer(data);
3874
+ // Strict String rather than StringValue's coercion, matching
3875
+ // vm_m_evalBytecode: bytecode is a binary blob, so there's no sensible
3876
+ // to_str-able stand-in for one, and refusing the coercion means no yield
3877
+ // point can open up between the checks above and the context access below.
3878
+ if (!RB_TYPE_P(r_bytecode, T_STRING))
3879
+ {
3880
+ VALUE r_class = rb_class_name(CLASS_OF(r_bytecode));
3881
+ rb_raise(rb_eTypeError, "Bytecode must be a String, got %s", StringValueCStr(r_class));
3882
+ }
3883
+ Check_Type(r_name, T_STRING);
3884
+
3885
+ // A module name crosses into QuickJS as raw bytes and comes back binary, so
3886
+ // it is compared and keyed on bytes here. Encoding-aware equality would call
3887
+ // a non-ASCII name different from the identical name the caller passed in
3888
+ // (UTF-8 from the registry), and this is also the form the normalize hook
3889
+ // looks up, since it builds its specifier from a C string too.
3890
+ VALUE r_key = rb_str_new(RSTRING_PTR(r_name), RSTRING_LEN(r_name));
3891
+
3892
+ if (RTEST(rb_hash_aref(data->preloaded_module_names, r_key)))
3893
+ return r_name;
3894
+
3895
+ // Deserialize with the GVL released. The read touches no Ruby and no
3896
+ // bridge — it only registers the def, deferring linking to import — so it
3897
+ // releases unconditionally (no can_eval_gvl_free gate): a pool of
3898
+ // per-thread VMs each preloading the same modules deserializes in parallel
3899
+ // instead of serializing through the GVL. RSTRING_PTR is GC-movable while
3900
+ // released, so the bytecode is copied to a buffer the region owns and frees.
3901
+ //
3902
+ // An async interrupt in the region's GVL re-acquire frees j_mod but leaves
3903
+ // the def js_new_module_def already registered in loaded_modules, untracked
3904
+ // by preloaded_module_names. That is reachable on a VM the caller keeps —
3905
+ // import(names, from:) preloads into a live VM, not just VM.new into a
3906
+ // half-built one — so it is worth spelling out what a retry does: the
3907
+ // untracked name misses the guard above, a second def is read under the same
3908
+ // name, js_find_loaded_module returns the first, and the retry records the
3909
+ // name. Behaviour recovers, at the cost of one orphan holding its bytecode
3910
+ // for the life of the context, so unregistering on interrupt isn't worth it.
3911
+ size_t buf_len;
3912
+ uint8_t *buf = (uint8_t *)copy_rstring_to_owned_buffer(r_bytecode, &buf_len, false);
3913
+ // The read runs no JS, so there is nothing here to budget — but a failed read
3914
+ // is rendered, and rendering reads name off a SyntaxError whose prototype the
3915
+ // guest may have given a throwing getter. That render must not run on the
3916
+ // clock a previous entry left behind. Conditional as in vm_m_compile: nested
3917
+ // under a bridge, the enclosing eval's budget governs.
3918
+ if (data->evals_in_flight == 0)
3919
+ arm_eval_timer(data);
3920
+ JSValue j_mod = run_bytecode_release_gvl(data, bytecode_read_job_run, buf, buf_len, true);
3921
+ if (JS_IsException(j_mod))
3922
+ return raise_from_js_exception_held(data); // raises
1628
3923
 
1629
- StringValue(r_code);
1630
- JSValue j_func = JS_Eval(data->context, RSTRING_PTR(r_code), RSTRING_LEN(r_code), filename,
1631
- JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC | JS_EVAL_FLAG_COMPILE_ONLY);
1632
- if (JS_IsException(j_func))
3924
+ if (JS_VALUE_GET_TAG(j_mod) != JS_TAG_MODULE)
1633
3925
  {
1634
- return to_rb_value(data->context, j_func); // raises Ruby exception
3926
+ JS_FreeValue(data->context, j_mod);
3927
+ rb_raise(rb_eTypeError, "bytecode is not an ES module (compile it with type: :module)");
1635
3928
  }
1636
3929
 
1637
- size_t out_len;
1638
- uint8_t *out_buf = JS_WriteObject(data->context, &out_len, j_func, JS_WRITE_OBJ_BYTECODE);
1639
- JS_FreeValue(data->context, j_func);
1640
- if (out_buf == NULL)
3930
+ js_module_set_import_meta(data->context, j_mod, FALSE, FALSE);
3931
+
3932
+ JSAtom j_baked = JS_GetModuleName(data->context, JS_VALUE_GET_PTR(j_mod));
3933
+ const char *baked = JS_AtomToCString(data->context, j_baked);
3934
+ VALUE r_baked = rb_str_new_cstr(baked ? baked : "");
3935
+ if (baked)
3936
+ JS_FreeCString(data->context, baked);
3937
+ JS_FreeAtom(data->context, j_baked);
3938
+
3939
+ // The module def is owned by ctx->loaded_modules (js_new_module_def leaves
3940
+ // it there with ref_count 1); JS_NewModuleValue dup'd it for us.
3941
+ JS_FreeValue(data->context, j_mod);
3942
+
3943
+ // A blob whose baked name disagrees with the name it was filed under would
3944
+ // be resolvable only by the baked one, so the guard above would never see
3945
+ // it again and every preload would read another copy. There is no way to
3946
+ // take the read back, so the VM is already polluted: raise and let the
3947
+ // caller discard it.
3948
+ if (!RTEST(rb_str_equal(r_baked, r_key)))
1641
3949
  {
1642
- VALUE r_msg = rb_str_new2("failed to serialize compiled bytecode");
1643
- rb_exc_raise(rb_funcall(QUICKJSRB_ERROR_FOR(QUICKJSRB_ROOT_RUNTIME_ERROR), rb_intern("new"), 2, r_msg, Qnil));
3950
+ rb_raise(rb_eArgError, "module bytecode is named %"PRIsVALUE", not %"PRIsVALUE,
3951
+ rb_str_inspect(r_baked), rb_str_inspect(r_name));
1644
3952
  }
1645
3953
 
1646
- VALUE r_bytecode = rb_str_new((const char *)out_buf, (long)out_len);
1647
- rb_enc_associate(r_bytecode, rb_ascii8bit_encoding());
1648
- js_free(data->context, out_buf);
1649
- return rb_obj_freeze(r_bytecode);
3954
+ rb_hash_aset(data->preloaded_module_names, rb_str_freeze(r_key), Qtrue);
3955
+ return r_name;
3956
+ }
3957
+
3958
+ // Number of sources the normalize hook is holding for the load hook to pick
3959
+ // up. Exposed only so tests can assert the cache doesn't accumulate entries
3960
+ // nothing will ever collect: when a loader resolves a specifier onto a
3961
+ // preloaded canonical, QuickJS finds the module and never calls the load hook,
3962
+ // so a source stashed for that name would sit there for the VM's lifetime.
3963
+ static VALUE vm_m_pendingModuleSourceCount(VALUE r_self)
3964
+ {
3965
+ VMData *data;
3966
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
3967
+
3968
+ return LONG2NUM(RHASH_SIZE(data->module_source_cache));
1650
3969
  }
1651
3970
 
1652
3971
  static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
@@ -1655,7 +3974,8 @@ static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
1655
3974
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1656
3975
 
1657
3976
  check_disposed(data);
1658
- check_oom_poisoned(data);
3977
+ check_vm_poisoned(data);
3978
+ check_js_entry_owner(data);
1659
3979
 
1660
3980
  if (!RB_TYPE_P(r_bytecode, T_STRING))
1661
3981
  {
@@ -1667,22 +3987,38 @@ static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
1667
3987
 
1668
3988
  arm_eval_timer(data);
1669
3989
 
1670
- // GVL held: user bytecode may invoke Ruby-bridged callbacks registered
1671
- // via define_function, which call Ruby APIs. A pure VM could release
1672
- // here with the same can_eval_gvl_free gate + buffer copy eval_code
1673
- // uses — left GVL-held deliberately until bytecode eval shows up as a
1674
- // parallelism bottleneck.
1675
- struct bytecode_load_job job = {data->context,
1676
- (const uint8_t *)RSTRING_PTR(r_bytecode),
1677
- (size_t)RSTRING_LEN(r_bytecode),
1678
- JS_UNDEFINED};
1679
- run_held_js_entry(data, bytecode_eval_await_body, (VALUE)&job);
1680
- if (JS_IsException(job.result))
1681
- return to_rb_value(data->context, job.result); // raises
3990
+ JSValue j_result;
3991
+ if (can_eval_gvl_free(data))
3992
+ {
3993
+ // Pure VM: no JS→Ruby bridge can fire during the run or the await's
3994
+ // job drain, so run load + await with the GVL released — warmer pools
3995
+ // executing one compiled bundle across per-thread VMs recover
3996
+ // multi-core scaling instead of serializing every run through the
3997
+ // GVL. The bytecode is copied because RSTRING_PTR is GC-movable
3998
+ // while the GVL is released.
3999
+ size_t buf_len;
4000
+ uint8_t *buf = (uint8_t *)copy_rstring_to_owned_buffer(r_bytecode, &buf_len, false);
4001
+ j_result = run_bytecode_release_gvl(data, bytecode_eval_await_job_run, buf, buf_len, true);
4002
+ }
4003
+ else
4004
+ {
4005
+ // Bridged path: user bytecode may invoke Ruby-bridged callbacks
4006
+ // registered via define_function, which call Ruby APIs — keep the GVL
4007
+ // held. With it held there's no compaction risk, so RSTRING_PTR is
4008
+ // usable without a copy.
4009
+ struct bytecode_load_job job = {data->context,
4010
+ (const uint8_t *)RSTRING_PTR(r_bytecode),
4011
+ (size_t)RSTRING_LEN(r_bytecode),
4012
+ JS_UNDEFINED};
4013
+ return run_held_js_checkpoint_entry(data, bytecode_eval_await_body, (VALUE)&job);
4014
+ }
4015
+
4016
+ if (JS_IsException(j_result))
4017
+ return raise_from_js_exception_held(data); // raises
1682
4018
 
1683
- JSValue j_returnedValue = JS_GetPropertyStr(data->context, job.result, "value");
1684
- JS_FreeValue(data->context, job.result);
1685
- return to_rb_return_value(data->context, j_returnedValue);
4019
+ JSValue j_returnedValue = JS_GetPropertyStr(data->context, j_result, "value");
4020
+ JS_FreeValue(data->context, j_result);
4021
+ return to_rb_return_value_held(data, j_returnedValue);
1686
4022
  }
1687
4023
 
1688
4024
  // Loads pre-compiled polyfill bytecode without arming the eval timer.
@@ -1709,13 +4045,21 @@ static VALUE vm_m_loadPolyfillBytecode(VALUE r_self, VALUE r_bytecode)
1709
4045
  VMData *data;
1710
4046
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1711
4047
 
1712
- // StringValue can invoke a non-String argument's to_str, which may
1713
- // yield the GVL — run it before the disposed check so nothing below
1714
- // touches a context freed by a concurrent dispose! in that window.
1715
- StringValue(r_bytecode);
4048
+ // The disposed check has to sit after the last GVL-yield point and before
4049
+ // the context is touched. Refusing a to_str-able stand-in removes the yield
4050
+ // instead of ordering around it: bytecode is a binary blob, so coercion buys
4051
+ // nothing, and without it there is no window for a concurrent dispose! to
4052
+ // free the context between the check and the load. Same guard as
4053
+ // vm_m_evalBytecode and _preload_module_bytecode.
4054
+ if (!RB_TYPE_P(r_bytecode, T_STRING))
4055
+ {
4056
+ VALUE r_class = rb_class_name(CLASS_OF(r_bytecode));
4057
+ rb_raise(rb_eTypeError, "Bytecode must be a String, got %s", StringValueCStr(r_class));
4058
+ }
1716
4059
 
1717
4060
  check_disposed(data);
1718
- check_oom_poisoned(data);
4061
+ check_vm_poisoned(data);
4062
+ check_js_entry_owner(data);
1719
4063
 
1720
4064
  // "Unbudgeted" needs enforcing, not just skipping arm_eval_timer: the
1721
4065
  // interrupt handler installed by a previous eval stays armed with that
@@ -1728,7 +4072,10 @@ static VALUE vm_m_loadPolyfillBytecode(VALUE r_self, VALUE r_bytecode)
1728
4072
  // from inside a bridge callback (e.g. an on_log listener) must stay
1729
4073
  // under the in-flight eval's budget, not erase it.
1730
4074
  if (data->evals_in_flight == 0)
4075
+ {
1731
4076
  JS_SetInterruptHandler(JS_GetRuntime(data->context), NULL, NULL);
4077
+ data->eval_timer_armed = false;
4078
+ }
1732
4079
 
1733
4080
  size_t buf_len = (size_t)RSTRING_LEN(r_bytecode);
1734
4081
  JSValue j_result;
@@ -1752,155 +4099,234 @@ static VALUE vm_m_loadPolyfillBytecode(VALUE r_self, VALUE r_bytecode)
1752
4099
  j_result = job.result;
1753
4100
  }
1754
4101
 
1755
- if (JS_IsException(j_result))
1756
- return to_rb_value(data->context, j_result); // raises
1757
-
1758
- // The compiled bytecode is async-wrapped (JS_EVAL_FLAG_ASYNC), so a
1759
- // top-level throw doesn't come back as JS_EXCEPTION — it comes back as
1760
- // a rejected promise. Surface it instead of silently shipping a VM
1761
- // whose polyfill half-ran. Re-throwing the reason routes it through
1762
- // to_rb_value's standard exception path, so interrupted/OOM mapping,
1763
- // oom_poisoned latching, and the on_log error dispatch all behave
1764
- // exactly as for a synchronous throw.
1765
- if (JS_PromiseState(data->context, j_result) == JS_PROMISE_REJECTED)
1766
- {
1767
- JSValue j_reason = JS_PromiseResult(data->context, j_result);
1768
- JS_FreeValue(data->context, j_result);
1769
- JS_Throw(data->context, j_reason); // consumes j_reason
1770
- return to_rb_value(data->context, JS_EXCEPTION); // raises
1771
- }
1772
-
1773
- JS_FreeValue(data->context, j_result);
4102
+ finish_polyfill_load(data, j_result); // raises unless settled fulfilled
1774
4103
  return Qnil;
1775
4104
  }
1776
4105
 
1777
- static VALUE vm_m_defineGlobalFunction(int argc, VALUE *argv, VALUE r_self)
4106
+ struct define_function_call
1778
4107
  {
1779
- rb_need_block();
1780
-
4108
+ VMData *data;
1781
4109
  VALUE r_name;
1782
4110
  VALUE r_flags;
1783
4111
  VALUE r_block;
1784
- rb_scan_args(argc, argv, "10*&", &r_name, &r_flags, &r_block);
4112
+ };
1785
4113
 
1786
- VMData *data;
1787
- TypedData_Get_Struct(r_self, VMData, &vm_type, data);
4114
+ // What a define holds while it resolves its path: the parent it is walking
4115
+ // and the two values the bridge function captures. Owned by an ensure from
4116
+ // before any of them exists, because resolution runs guest JS — the first
4117
+ // segment is an eval and every later one a property read, any of which can
4118
+ // be a getter that reaches a bridge, raises, or outlives the budget — and the
4119
+ // install itself can run a setter. Every one of those unwinds through here.
4120
+ struct define_function_install
4121
+ {
4122
+ JSContext *ctx;
4123
+ JSValue j_parent;
4124
+ JSValue ruby_data[2];
4125
+ };
1788
4126
 
1789
- check_disposed(data);
1790
- check_no_gvl_release_in_flight(data);
4127
+ static VALUE define_function_release(VALUE p)
4128
+ {
4129
+ struct define_function_install *install = (struct define_function_install *)p;
4130
+ JS_FreeValue(install->ctx, install->j_parent);
4131
+ JS_FreeValue(install->ctx, install->ruby_data[0]);
4132
+ JS_FreeValue(install->ctx, install->ruby_data[1]);
4133
+ return Qnil;
4134
+ }
1791
4135
 
1792
- if (RB_TYPE_P(r_name, T_ARRAY))
1793
- {
1794
- long path_len = RARRAY_LEN(r_name);
1795
- if (path_len < 1)
1796
- rb_raise(rb_eArgError, "function's path array must not be empty");
4136
+ struct define_function_run
4137
+ {
4138
+ struct define_function_call *call;
4139
+ struct define_function_install *install;
4140
+ VALUE r_segs; // the segments as Strings, stringified once, outside the region
4141
+ VALUE r_key_sym; // the key the bridge looks the block up by
4142
+ };
1797
4143
 
1798
- for (long i = 0; i < path_len; i++)
1799
- {
1800
- VALUE r_seg = RARRAY_AREF(r_name, i);
1801
- if (!(SYMBOL_P(r_seg) || RB_TYPE_P(r_seg, T_STRING)))
1802
- rb_raise(rb_eTypeError, "function's name should be a Symbol or a String");
1803
- }
4144
+ // Two answers a resolution step can give that are not the object asked for,
4145
+ // and they are different failures. A pending exception is the guest's own —
4146
+ // a bridge raising in a getter, the budget lapsing, a dispose! reached from
4147
+ // the path — and folding it into "not an object" both blamed the caller's
4148
+ // path for it and left the Ruby exception behind it parked in alive_objects
4149
+ // for the life of the VM. Only a value that really is not an object is the
4150
+ // caller's mistake.
4151
+ static void check_path_segment(JSContext *ctx, JSValueConst j_val, const char *segment)
4152
+ {
4153
+ if (JS_IsException(j_val))
4154
+ raise_js_exception(ctx);
4155
+ if (!JS_IsObject(j_val))
4156
+ rb_raise(rb_eArgError, "cannot define function: '%s' is not an object", segment);
4157
+ }
4158
+
4159
+ static VALUE define_function_resolve_and_install(VALUE p)
4160
+ {
4161
+ struct define_function_run *run = (struct define_function_run *)p;
4162
+ VMData *data = run->call->data;
4163
+ JSContext *ctx = data->context;
4164
+ struct define_function_install *install = run->install;
4165
+ VALUE r_segs = run->r_segs;
4166
+ long path_len = RARRAY_LEN(r_segs);
4167
+
4168
+ install->ruby_data[0] = JS_NewInt64(ctx, (int64_t)SYM2ID(run->r_key_sym));
4169
+ install->ruby_data[1] = JS_NewBool(ctx, RTEST(rb_funcall(run->call->r_flags, rb_intern("include?"), 1, ID2SYM(rb_intern("async")))));
1804
4170
 
1805
- // Build internal lookup key by joining path segments with "."
1806
- // e.g. ["myLib", "hello"] -> :"myLib.hello"
1807
- VALUE r_segs = rb_ary_new();
1808
- for (long i = 0; i < path_len; i++)
1809
- rb_ary_push(r_segs, rb_funcall(RARRAY_AREF(r_name, i), rb_intern("to_s"), 0));
1810
- VALUE r_key_str = rb_funcall(r_segs, rb_intern("join"), 1, rb_str_new2("."));
1811
- VALUE r_key_sym = rb_funcall(r_key_str, rb_intern("to_sym"), 0);
1812
- rb_hash_aset(data->defined_functions, r_key_sym, r_block);
1813
-
1814
- VALUE r_func_seg_str = rb_funcall(RARRAY_AREF(r_name, path_len - 1), rb_intern("to_s"), 0);
1815
- char *funcName = StringValueCStr(r_func_seg_str);
1816
-
1817
- JSValueConst ruby_data[2];
1818
- ruby_data[0] = JS_NewInt64(data->context, (int64_t)SYM2ID(r_key_sym));
1819
- ruby_data[1] = JS_NewBool(data->context, RTEST(rb_funcall(r_flags, rb_intern("include?"), 1, ID2SYM(rb_intern("async")))));
1820
-
1821
- // Resolve the parent object to attach the function to.
1822
- // For a single-element array, parent is the global object.
1823
- // For multi-element arrays, traverse path[0..n-2] using JS_Eval for the first
1824
- // segment (so lexical const/let bindings are resolved, not just global properties)
1825
- // and JS_GetPropertyStr for subsequent segments.
1826
- JSValue j_parent;
1827
- if (path_len == 1)
4171
+ if (path_len > 1)
4172
+ {
4173
+ // Resolving the path runs guest JS under whatever interrupt handler the
4174
+ // previous entry left armed. Refresh the clock, or a lapsed budget fires in
4175
+ // the first getter and is reported as that getter's fault. Unconditional,
4176
+ // as it always was here: nested under a bridge this resets the enclosing
4177
+ // eval's clock, which vm_m_compile's comment records as a class deferred to
4178
+ // its own PR rather than something to change one entry point at a time.
4179
+ arm_eval_timer(data);
4180
+
4181
+ // The first segment is not a lookup, it is an eval: `myLib` can be a
4182
+ // lexical binding as well as a global property, and either can be an
4183
+ // accessor.
4184
+ const char *first_seg = StringValueCStr(RARRAY_AREF(r_segs, 0));
4185
+ install->j_parent = JS_Eval(ctx, first_seg, strlen(first_seg), vmInternalFilename, JS_EVAL_TYPE_GLOBAL);
4186
+ check_path_segment(ctx, install->j_parent, first_seg);
4187
+
4188
+ for (long i = 1; i < path_len - 1; i++)
1828
4189
  {
1829
- j_parent = JS_GetGlobalObject(data->context);
4190
+ const char *seg = StringValueCStr(RARRAY_AREF(r_segs, i));
4191
+ JSValue j_next = JS_GetPropertyStr(ctx, install->j_parent, seg);
4192
+ JS_FreeValue(ctx, install->j_parent);
4193
+ install->j_parent = j_next;
4194
+ check_path_segment(ctx, j_next, seg);
1830
4195
  }
1831
- else
1832
- {
1833
- VALUE r_first_str = rb_funcall(RARRAY_AREF(r_name, 0), rb_intern("to_s"), 0);
1834
- const char *first_seg = StringValueCStr(r_first_str);
1835
- // This lookup eval runs under whatever interrupt handler the
1836
- // previous eval left armed; refresh the clock so a lapsed budget
1837
- // can't misfire here and masquerade as "'%s' is not an object".
4196
+ }
4197
+ else
4198
+ {
4199
+ // A plain name never armed before, and the only JS it can run is a setter
4200
+ // on globalThis. It gets a clock of its own only as the outermost entry:
4201
+ // nested under a bridge, the enclosing eval's budget governs, and arming
4202
+ // here unconditionally let a bridge that defined a function on every
4203
+ // call keep the enclosing eval alive past its timeout_msec.
4204
+ if (data->evals_in_flight == 1)
1838
4205
  arm_eval_timer(data);
1839
- j_parent = JS_Eval(data->context, first_seg, strlen(first_seg), vmInternalFilename, JS_EVAL_TYPE_GLOBAL);
4206
+ install->j_parent = JS_GetGlobalObject(ctx);
4207
+ }
1840
4208
 
1841
- if (JS_IsException(j_parent) || !JS_IsObject(j_parent))
1842
- {
1843
- JS_FreeValue(data->context, j_parent);
1844
- JS_FreeValue(data->context, ruby_data[0]);
1845
- JS_FreeValue(data->context, ruby_data[1]);
1846
- rb_raise(rb_eArgError, "cannot define function: '%s' is not an object", first_seg);
1847
- }
4209
+ const char *funcName = StringValueCStr(RARRAY_AREF(r_segs, path_len - 1));
1848
4210
 
1849
- for (long i = 1; i < path_len - 1; i++)
1850
- {
1851
- VALUE r_seg_str = rb_funcall(RARRAY_AREF(r_name, i), rb_intern("to_s"), 0);
1852
- JSValue j_next = JS_GetPropertyStr(data->context, j_parent, StringValueCStr(r_seg_str));
1853
- JS_FreeValue(data->context, j_parent);
4211
+ JSValue j_func = JS_NewCFunctionData(ctx, js_quickjsrb_call_global, 1, 0, 2, install->ruby_data);
4212
+ if (JS_IsException(j_func))
4213
+ return raise_js_exception(ctx);
4214
+
4215
+ // JS_SetPropertyStr consumes j_func on every path and, with JS_PROP_THROW,
4216
+ // answers -1 with the TypeError pending when the target refuses — a frozen
4217
+ // parent, a frozen globalThis, a setter that throws. Ignored, that was a
4218
+ // define that reported success for a function which did not exist, and a
4219
+ // stale exception left on the context for whatever ran next.
4220
+ if (JS_SetPropertyStr(ctx, install->j_parent, funcName, j_func) < 0)
4221
+ return raise_js_exception(ctx);
4222
+
4223
+ // Recorded only now that the property is installed. can_eval_gvl_free reads
4224
+ // this table as "a bridge can fire during eval", and an entry left behind by
4225
+ // a define that failed took the VM off the GVL-release path for good.
4226
+ rb_hash_aset(data->defined_functions, run->r_key_sym, run->call->r_block);
4227
+ return Qnil;
4228
+ }
1854
4229
 
1855
- if (JS_IsException(j_next) || !JS_IsObject(j_next))
1856
- {
1857
- JS_FreeValue(data->context, j_next);
1858
- JS_FreeValue(data->context, ruby_data[0]);
1859
- JS_FreeValue(data->context, ruby_data[1]);
1860
- rb_raise(rb_eArgError, "cannot define function: '%s' is not an object", StringValueCStr(r_seg_str));
1861
- }
1862
- j_parent = j_next;
1863
- }
1864
- }
4230
+ static VALUE define_global_function_body(VALUE p)
4231
+ {
4232
+ struct define_function_call *call = (struct define_function_call *)p;
4233
+ VMData *data = call->data;
4234
+ VALUE r_name = call->r_name;
1865
4235
 
1866
- JS_SetPropertyStr(
1867
- data->context, j_parent, funcName,
1868
- JS_NewCFunctionData(data->context, js_quickjsrb_call_global, 1, 0, 2, ruby_data));
1869
- JS_FreeValue(data->context, j_parent);
1870
- JS_FreeValue(data->context, ruby_data[0]);
1871
- JS_FreeValue(data->context, ruby_data[1]);
1872
-
1873
- VALUE r_result = rb_ary_new();
1874
- for (long i = 0; i < path_len; i++)
1875
- rb_ary_push(r_result, rb_funcall(RARRAY_AREF(r_name, i), rb_intern("to_sym"), 0));
1876
- return r_result;
1877
- }
1878
- else if (SYMBOL_P(r_name) || RB_TYPE_P(r_name, T_STRING))
4236
+ // A plain name is a path of one segment whose parent is the global object;
4237
+ // the only difference is what the caller gets back.
4238
+ bool as_path = RB_TYPE_P(r_name, T_ARRAY);
4239
+ VALUE r_path = as_path ? r_name : rb_ary_new3(1, r_name);
4240
+ long path_len = RARRAY_LEN(r_path);
4241
+ if (path_len < 1)
4242
+ rb_raise(rb_eArgError, "function's path array must not be empty");
4243
+
4244
+ for (long i = 0; i < path_len; i++)
1879
4245
  {
1880
- VALUE r_name_sym = rb_funcall(r_name, rb_intern("to_sym"), 0);
4246
+ VALUE r_seg = RARRAY_AREF(r_path, i);
4247
+ if (!(SYMBOL_P(r_seg) || RB_TYPE_P(r_seg, T_STRING)))
4248
+ rb_raise(rb_eTypeError, "function's name should be a Symbol or a String");
4249
+ }
1881
4250
 
1882
- rb_hash_aset(data->defined_functions, r_name_sym, r_block);
1883
- VALUE r_name_str = rb_funcall(r_name, rb_intern("to_s"), 0);
1884
- char *funcName = StringValueCStr(r_name_str);
4251
+ // Stringified once, here, and handed to the resolution as Strings: to_s on
4252
+ // a caller's object is a yield point and can raise, and neither belongs
4253
+ // inside the region that holds JS values. The bridge looks its block up by
4254
+ // the segments joined with ".", e.g. ["myLib", "hello"] -> :"myLib.hello",
4255
+ // and a plain name as itself.
4256
+ VALUE r_segs = rb_ary_new();
4257
+ for (long i = 0; i < path_len; i++)
4258
+ rb_ary_push(r_segs, rb_funcall(RARRAY_AREF(r_path, i), rb_intern("to_s"), 0));
4259
+ VALUE r_key_sym = rb_funcall(rb_funcall(r_segs, rb_intern("join"), 1, rb_str_new2(".")), rb_intern("to_sym"), 0);
4260
+
4261
+ struct define_function_install install = {data->context, JS_UNDEFINED, {JS_UNDEFINED, JS_UNDEFINED}};
4262
+ struct define_function_run run = {call, &install, r_segs, r_key_sym};
4263
+ rb_ensure(define_function_resolve_and_install, (VALUE)&run, define_function_release, (VALUE)&install);
4264
+
4265
+ if (!as_path)
4266
+ return r_key_sym;
4267
+
4268
+ VALUE r_result = rb_ary_new();
4269
+ for (long i = 0; i < path_len; i++)
4270
+ rb_ary_push(r_result, rb_funcall(RARRAY_AREF(r_segs, i), rb_intern("to_sym"), 0));
4271
+ return r_result;
4272
+ }
1885
4273
 
1886
- JSValueConst ruby_data[2];
1887
- ruby_data[0] = JS_NewInt64(data->context, (int64_t)SYM2ID(r_name_sym));
1888
- ruby_data[1] = JS_NewBool(data->context, RTEST(rb_funcall(r_flags, rb_intern("include?"), 1, ID2SYM(rb_intern("async")))));
4274
+ // Registering a function is a JS entry point like any other, and was the one
4275
+ // that never said so. Two independent problems land on this call, and routing
4276
+ // the body through run_held_js_entry is what closes both.
4277
+ //
4278
+ // Lifetime: the body resolves the path with JS_Eval and JS_GetPropertyStr,
4279
+ // either of which can land on an accessor property and run user JS that calls
4280
+ // a bridge — and it interleaves that with rb_funcall(to_s) on caller-supplied
4281
+ // objects, which yields the GVL. With evals_in_flight left at zero throughout,
4282
+ // dispose! did not refuse:
4283
+ //
4284
+ // vm.define_function('boom') { vm.dispose!; 1 }
4285
+ // vm.eval_code("Object.defineProperty(globalThis, 'myLib', { get() { boom(); return {}; } }); 0")
4286
+ // vm.define_function(['myLib', 'hello']) { 1 } # SIGSEGV
4287
+ //
4288
+ // dispose! succeeds from inside the getter, and the traversal then runs
4289
+ // JS_GetPropertyStr and JS_FreeValue against a freed JSContext. Elevating the
4290
+ // counter for the whole body is what every other JS entry point already does,
4291
+ // and it turns that into the ThreadError dispose! owes the caller.
4292
+ //
4293
+ // Concurrency: that same GVL yield lets a second thread in. A bare owner check
4294
+ // would pass on an idle VM and record nothing, so the second thread passes the
4295
+ // same idle check and walks into JS_Eval alongside the traversal. Only
4296
+ // claiming the VM closes that.
4297
+ //
4298
+ // Neither reason subsumes the other — one is a single thread freeing the
4299
+ // context under its own traversal, the other is two threads inside JS_Eval at
4300
+ // once — so retiring one of them is not grounds for unwinding the routing.
4301
+ //
4302
+ // The absent check_js_entry_owner beside check_disposed is deliberate, not the
4303
+ // oversight it looks like next to the other six entry points: this is the one
4304
+ // where nothing touches the runtime before the counted region, so the claim
4305
+ // inside run_held_js_entry is the whole guard. check_no_gvl_release_in_flight
4306
+ // and rb_scan_args above it read no runtime state; anything added there that
4307
+ // does needs the explicit check back.
4308
+ static VALUE vm_m_defineGlobalFunction(int argc, VALUE *argv, VALUE r_self)
4309
+ {
4310
+ rb_need_block();
1889
4311
 
1890
- JSValue j_global = JS_GetGlobalObject(data->context);
1891
- JS_SetPropertyStr(
1892
- data->context, j_global, funcName,
1893
- JS_NewCFunctionData(data->context, js_quickjsrb_call_global, 1, 0, 2, ruby_data));
1894
- JS_FreeValue(data->context, j_global);
1895
- JS_FreeValue(data->context, ruby_data[0]);
1896
- JS_FreeValue(data->context, ruby_data[1]);
4312
+ VALUE r_name;
4313
+ VALUE r_flags;
4314
+ VALUE r_block;
4315
+ rb_scan_args(argc, argv, "10*&", &r_name, &r_flags, &r_block);
1897
4316
 
1898
- return r_name_sym;
1899
- }
1900
- else
1901
- {
1902
- rb_raise(rb_eTypeError, "function's name should be a Symbol or a String");
1903
- }
4317
+ VMData *data;
4318
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
4319
+
4320
+ check_disposed(data);
4321
+ check_no_gvl_release_in_flight(data);
4322
+
4323
+ struct define_function_call call = {
4324
+ .data = data,
4325
+ .r_name = r_name,
4326
+ .r_flags = r_flags,
4327
+ .r_block = r_block,
4328
+ };
4329
+ return run_held_js_entry(data, define_global_function_body, (VALUE)&call);
1904
4330
  }
1905
4331
 
1906
4332
  struct js_entry_call
@@ -1910,14 +4336,79 @@ struct js_entry_call
1910
4336
  VMData *data;
1911
4337
  };
1912
4338
 
4339
+ // The converted arguments of a call. Owned by an ensure from before the first
4340
+ // conversion, because to_js_value runs inspect on the caller's own objects and
4341
+ // any one of them can raise part-way through — and the ones already converted
4342
+ // used to go with it, along with the array.
4343
+ struct js_call_args
4344
+ {
4345
+ JSContext *ctx;
4346
+ JSValue *j_args;
4347
+ int nargs;
4348
+ };
4349
+
4350
+ static VALUE js_call_args_release(VALUE p)
4351
+ {
4352
+ struct js_call_args *args = (struct js_call_args *)p;
4353
+ for (int i = 0; i < args->nargs; i++)
4354
+ JS_FreeValue(args->ctx, args->j_args[i]);
4355
+ xfree(args->j_args);
4356
+ return Qnil;
4357
+ }
4358
+
4359
+ struct js_call_run
4360
+ {
4361
+ struct js_entry_call *call;
4362
+ struct js_call_args *args;
4363
+ };
4364
+
4365
+ static VALUE call_global_function_run(VALUE p);
4366
+
1913
4367
  static VALUE call_global_function_body(VALUE p)
1914
4368
  {
1915
4369
  struct js_entry_call *call = (struct js_entry_call *)p;
1916
- int argc = call->argc;
4370
+ struct js_call_args args = {call->data->context, NULL, call->argc - 1};
4371
+ if (args.nargs > 0)
4372
+ {
4373
+ args.j_args = xmalloc2(args.nargs, sizeof(JSValue));
4374
+ for (int i = 0; i < args.nargs; i++)
4375
+ args.j_args[i] = JS_UNDEFINED;
4376
+ }
4377
+ struct js_call_run run = {call, &args};
4378
+ return rb_ensure(call_global_function_run, (VALUE)&run, js_call_args_release, (VALUE)&args);
4379
+ }
4380
+
4381
+ static VALUE call_global_function_run(VALUE p)
4382
+ {
4383
+ struct js_call_run *run = (struct js_call_run *)p;
4384
+ struct js_entry_call *call = run->call;
4385
+ struct js_call_args *args = run->args;
1917
4386
  VALUE *argv = call->argv;
1918
4387
  VMData *data = call->data;
1919
4388
  VALUE r_name = argv[0];
1920
4389
 
4390
+ // Converted first, under a clock of its own. Mostly this is Ruby work —
4391
+ // inspect on the caller's objects, allocation, a GVL yield to another thread
4392
+ // — and none of it is the guest's to pay for, which is why the arm above the
4393
+ // resolution below starts the budget over. But a conversion can still run JS:
4394
+ // a File argument calls the proxy creator, which polls the interrupt handler
4395
+ // on the way, so without an arm here it ran on whatever clock the previous
4396
+ // entry left — a lapsed one interrupts the conversion at random and hands the
4397
+ // function a JS_EXCEPTION for an argument. Two arms, but not the two budgets
4398
+ // the previous commit had: no guest-written JS runs between them unless the
4399
+ // guest has replaced Proxy, and then it is bounded rather than unbounded.
4400
+ // (A Bignum used to call Number() here too; it converts without asking the
4401
+ // guest for anything now.)
4402
+ arm_eval_timer(data);
4403
+ for (int i = 0; i < args->nargs; i++)
4404
+ {
4405
+ args->j_args[i] = to_js_value(data->context, argv[i + 1]);
4406
+ // An argument that could not be built is not handed to the call as the
4407
+ // sentinel. The ensure that owns j_args releases what was built already.
4408
+ if (JS_IsException(args->j_args[i]))
4409
+ raise_js_exception(data->context); // raises
4410
+ }
4411
+
1921
4412
  JSValue j_this = JS_UNDEFINED;
1922
4413
  JSValue j_func;
1923
4414
 
@@ -1965,6 +4456,17 @@ static VALUE call_global_function_body(VALUE p)
1965
4456
  rb_raise(rb_eTypeError, "function's name should be a Symbol or a String");
1966
4457
  }
1967
4458
 
4459
+ // Armed once, above the resolution, for the reason define_global_function_body
4460
+ // and vm_m_import give: resolving the path runs JS — the JS_Eval of the first
4461
+ // segment, then a property read per segment, any of which can be a getter —
4462
+ // and it ran under whatever the previous entry point left behind. A lapsed
4463
+ // clock interrupted it spuriously; none at all, after a polyfill load's
4464
+ // disarm, left it unbounded. Once, because resolution and the call are one
4465
+ // budget: a second arm beside the JS_Call handed a single call twice
4466
+ // timeout_msec of guest JS, half in a getter on the path and half in the
4467
+ // function it found.
4468
+ arm_eval_timer(data);
4469
+
1968
4470
  {
1969
4471
  long path_len = RARRAY_LEN(r_path);
1970
4472
 
@@ -2017,31 +4519,13 @@ static VALUE call_global_function_body(VALUE p)
2017
4519
  return Qnil;
2018
4520
  }
2019
4521
 
2020
- int nargs = argc - 1;
2021
- JSValue *j_args = NULL;
2022
- if (nargs > 0)
2023
- {
2024
- j_args = (JSValue *)malloc(sizeof(JSValue) * nargs);
2025
- for (int i = 0; i < nargs; i++)
2026
- j_args[i] = to_js_value(data->context, argv[i + 1]);
2027
- }
2028
-
2029
- clock_gettime(CLOCK_MONOTONIC, &data->eval_time->started_at);
2030
- JS_SetInterruptHandler(JS_GetRuntime(data->context), interrupt_handler, data->eval_time);
2031
-
2032
- JSValue j_result = JS_Call(data->context, j_func, j_this, nargs, (JSValueConst *)j_args);
4522
+ JSValue j_result = JS_Call(data->context, j_func, j_this, args->nargs, (JSValueConst *)args->j_args);
2033
4523
 
2034
4524
  JS_FreeValue(data->context, j_func);
2035
4525
  JS_FreeValue(data->context, j_this);
2036
- if (j_args)
2037
- {
2038
- for (int i = 0; i < nargs; i++)
2039
- JS_FreeValue(data->context, j_args[i]);
2040
- free(j_args);
2041
- }
2042
4526
 
2043
4527
  // js_std_await handles both async (promise) and sync results; frees j_result
2044
- return to_rb_return_value(data->context, js_std_await(data->context, j_result));
4528
+ return to_rb_return_value(data->context, quickjsrb_host_await(data->context, j_result));
2045
4529
  }
2046
4530
 
2047
4531
  static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
@@ -2053,7 +4537,8 @@ static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
2053
4537
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2054
4538
 
2055
4539
  check_disposed(data);
2056
- check_oom_poisoned(data);
4540
+ check_vm_poisoned(data);
4541
+ check_js_entry_owner(data);
2057
4542
 
2058
4543
  // evals_in_flight stays elevated for the whole call, not just the
2059
4544
  // JS_Eval / JS_Call moments: the body holds live JSValues across Ruby
@@ -2061,7 +4546,7 @@ static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
2061
4546
  // conversion), and a concurrent dispose! landing in such a gap would
2062
4547
  // free the runtime out from under them.
2063
4548
  struct js_entry_call call = {argc, argv, data};
2064
- return run_held_js_entry(data, call_global_function_body, (VALUE)&call);
4549
+ return run_held_js_checkpoint_entry(data, call_global_function_body, (VALUE)&call);
2065
4550
  }
2066
4551
 
2067
4552
  static VALUE vm_m_set_module_loader(VALUE r_self, VALUE r_loader)
@@ -2072,6 +4557,12 @@ static VALUE vm_m_set_module_loader(VALUE r_self, VALUE r_loader)
2072
4557
  if (!NIL_P(r_loader) && !rb_obj_is_kind_of(r_loader, rb_cProc))
2073
4558
  rb_raise(rb_eTypeError, "module_loader must be a Proc or nil");
2074
4559
 
4560
+ // register_module_loader_funcs below dereferences the context, so a
4561
+ // disposed VM was a use-after-free here, and a VM being evaluated by
4562
+ // another thread means swapping the loader out from under a running
4563
+ // import. Neither was checked.
4564
+ check_disposed(data);
4565
+ check_js_entry_owner(data);
2075
4566
  check_no_gvl_release_in_flight(data);
2076
4567
  data->module_loader = r_loader;
2077
4568
  // Stale entries from the previous loader's policy would survive the
@@ -2125,14 +4616,22 @@ static VALUE import_body(VALUE p)
2125
4616
  VALUE r_custom_exposure = rb_hash_aref(r_opts, ID2SYM(rb_intern("code_to_expose")));
2126
4617
 
2127
4618
  char *filename;
4619
+ char generated_filename[QUICKJSRB_GENERATED_NAME_SIZE];
2128
4620
  VALUE r_seeded_key = Qnil;
2129
4621
  if (!NIL_P(r_filename))
2130
4622
  {
4623
+ // Borrowed for the rest of the call, so r_filename has to outlive the
4624
+ // bridge snprintf below. A literal String is rooted by the kwargs hash
4625
+ // through argv, but StringValueCStr writes a coerced String back through
4626
+ // &r_filename, and a to_str result is referenced by nothing else — only
4627
+ // this local, which the compiler is free to treat as dead right here
4628
+ // while filename is still being read. Guarded after the last read.
2131
4629
  filename = StringValueCStr(r_filename);
2132
4630
  }
2133
4631
  else
2134
4632
  {
2135
- filename = random_string();
4633
+ random_filename(generated_filename);
4634
+ filename = generated_filename;
2136
4635
  char *source = StringValueCStr(r_from);
2137
4636
  JSValue module = JS_Eval(data->context, source, strlen(source), filename, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
2138
4637
  if (JS_IsException(module))
@@ -2179,6 +4678,7 @@ static VALUE import_body(VALUE p)
2179
4678
  int length = snprintf(NULL, 0, importAndGlobalizeModule, import_name, filename, globalize);
2180
4679
  char *result = (char *)malloc(length + 1);
2181
4680
  snprintf(result, length + 1, importAndGlobalizeModule, import_name, filename, globalize);
4681
+ RB_GC_GUARD(r_filename);
2182
4682
 
2183
4683
  JSValue j_codeResult = JS_Eval(data->context, result, strlen(result), vmInternalFilename, JS_EVAL_TYPE_MODULE);
2184
4684
  free(result);
@@ -2188,7 +4688,7 @@ static VALUE import_body(VALUE p)
2188
4688
  // Module eval returns a Promise. Awaiting it surfaces top-level throws,
2189
4689
  // rejected dynamic imports, and rejected top-level awaits as Ruby
2190
4690
  // exceptions instead of silently dropping them.
2191
- JSValue j_awaited = js_std_await(data->context, j_codeResult);
4691
+ JSValue j_awaited = quickjsrb_host_await(data->context, j_codeResult);
2192
4692
  if (JS_IsException(j_awaited))
2193
4693
  return to_rb_value(data->context, j_awaited);
2194
4694
  JS_FreeValue(data->context, j_awaited);
@@ -2205,7 +4705,8 @@ static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
2205
4705
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2206
4706
 
2207
4707
  check_disposed(data);
2208
- check_oom_poisoned(data);
4708
+ check_vm_poisoned(data);
4709
+ check_js_entry_owner(data);
2209
4710
 
2210
4711
  // Module top-level code is user JS like any eval — budget it. Without
2211
4712
  // this, import ran under whatever handler the previous entry point
@@ -2219,13 +4720,14 @@ static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
2219
4720
  // evals_in_flight elevated for the whole call so a concurrent dispose!
2220
4721
  // can't free the runtime in those gaps.
2221
4722
  struct js_entry_call call = {argc, argv, data};
2222
- return run_held_js_entry(data, import_body, (VALUE)&call);
4723
+ return run_held_js_checkpoint_entry(data, import_body, (VALUE)&call);
2223
4724
  }
2224
4725
 
2225
4726
  RUBY_FUNC_EXPORTED void Init_quickjsrb(void)
2226
4727
  {
2227
4728
  rb_require("json");
2228
4729
  rb_require("securerandom");
4730
+ quickjsrb_init_handle_source();
2229
4731
 
2230
4732
  VALUE r_module_quickjs = rb_define_module("Quickjs");
2231
4733
  r_define_constants(r_module_quickjs);
@@ -2236,6 +4738,9 @@ RUBY_FUNC_EXPORTED void Init_quickjsrb(void)
2236
4738
  rb_define_method(r_class_vm, "initialize", vm_m_initialize, -1);
2237
4739
  rb_define_method(r_class_vm, "eval_code", vm_m_evalCode, -1);
2238
4740
  rb_define_private_method(r_class_vm, "_compile_to_bytecode", vm_m_compile, -1);
4741
+ rb_define_private_method(r_class_vm, "_compile_module_to_bytecode", vm_m_compileModule, 2);
4742
+ rb_define_private_method(r_class_vm, "_preload_module_bytecode", vm_m_preloadModuleBytecode, 2);
4743
+ rb_define_private_method(r_class_vm, "_pending_module_source_count", vm_m_pendingModuleSourceCount, 0);
2239
4744
  rb_define_private_method(r_class_vm, "_run_bytecode", vm_m_evalBytecode, 1);
2240
4745
  rb_define_private_method(r_class_vm, "_load_polyfill_bytecode", vm_m_loadPolyfillBytecode, 1);
2241
4746
  rb_define_method(r_class_vm, "call", vm_m_callGlobalFunction, -1);
@@ -2248,6 +4753,7 @@ RUBY_FUNC_EXPORTED void Init_quickjsrb(void)
2248
4753
  rb_define_method(r_class_vm, "memory_usage", vm_m_memoryUsage, 0);
2249
4754
  rb_define_method(r_class_vm, "gc!", vm_m_runGC, 0);
2250
4755
  rb_define_method(r_class_vm, "memory_poisoned?", vm_m_memoryPoisoned, 0);
4756
+ rb_define_method(r_class_vm, "poisoned?", vm_m_poisoned, 0);
2251
4757
  rb_define_method(r_class_vm, "dispose!", vm_m_dispose, 0);
2252
4758
  rb_define_method(r_class_vm, "disposed?", vm_m_disposed, 0);
2253
4759
  rb_define_method(r_class_vm, "drain_jobs!", vm_m_drainJobs, 0);
@@ -2259,6 +4765,7 @@ static VALUE vm_m_memoryUsage(VALUE r_self)
2259
4765
  VMData *data;
2260
4766
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2261
4767
  check_disposed(data);
4768
+ check_js_entry_owner(data);
2262
4769
  JSMemoryUsage s;
2263
4770
  JS_ComputeMemoryUsage(JS_GetRuntime(data->context), &s);
2264
4771
  VALUE h = rb_hash_new();
@@ -2282,6 +4789,7 @@ static VALUE vm_m_runGC(VALUE r_self)
2282
4789
  VMData *data;
2283
4790
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2284
4791
  check_disposed(data);
4792
+ check_js_entry_owner(data);
2285
4793
  JS_RunGC(JS_GetRuntime(data->context));
2286
4794
  return Qnil;
2287
4795
  }
@@ -2291,14 +4799,18 @@ static VALUE drain_jobs_body(VALUE p)
2291
4799
  VMData *data = (VMData *)p;
2292
4800
  JSRuntime *runtime = JS_GetRuntime(data->context);
2293
4801
  int executed = 0;
2294
- for (;;)
4802
+ if (JS_IsJobPending(runtime))
2295
4803
  {
2296
- int err = JS_ExecutePendingJob(runtime, NULL);
2297
- if (err == 0)
2298
- break;
2299
- if (err < 0)
2300
- return to_rb_value(data->context, JS_EXCEPTION); // raises
2301
- executed++;
4804
+ arm_eval_timer(data);
4805
+ for (;;)
4806
+ {
4807
+ int err = JS_ExecutePendingJob(runtime, NULL);
4808
+ if (err == 0)
4809
+ break;
4810
+ if (err < 0)
4811
+ return to_rb_value(data->context, JS_EXCEPTION); // raises
4812
+ executed++;
4813
+ }
2302
4814
  }
2303
4815
  return INT2NUM(executed);
2304
4816
  }
@@ -2309,14 +4821,15 @@ static VALUE vm_m_drainJobs(VALUE r_self)
2309
4821
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2310
4822
 
2311
4823
  check_disposed(data);
2312
- check_oom_poisoned(data);
4824
+ check_vm_poisoned(data);
4825
+ check_js_entry_owner(data);
2313
4826
 
2314
- if (!JS_IsJobPending(JS_GetRuntime(data->context)))
4827
+ // Rejections can be pending with no job queued.
4828
+ if (!JS_IsJobPending(JS_GetRuntime(data->context)) &&
4829
+ data->pending_rejections.live == 0)
2315
4830
  return INT2NUM(0);
2316
4831
 
2317
- arm_eval_timer(data);
2318
-
2319
- return run_held_js_entry(data, drain_jobs_body, (VALUE)data);
4832
+ return run_held_js_checkpoint_entry(data, drain_jobs_body, (VALUE)data);
2320
4833
  }
2321
4834
 
2322
4835
  static VALUE vm_m_memoryPoisoned(VALUE r_self)
@@ -2326,18 +4839,49 @@ static VALUE vm_m_memoryPoisoned(VALUE r_self)
2326
4839
  return data->oom_poisoned ? Qtrue : Qfalse;
2327
4840
  }
2328
4841
 
4842
+ // Answers for either latch, where memory_poisoned? answers only for its own.
4843
+ // A VM whose handle source broke refuses every entry point while
4844
+ // memory_poisoned? says false, which leaves a host following the README's
4845
+ // recycle pattern holding one that looks healthy and does nothing. The two are
4846
+ // worth telling apart: recreating the VM clears an out-of-memory, and does not
4847
+ // clear a SecureRandom that is still returning nil.
4848
+ static VALUE vm_m_poisoned(VALUE r_self)
4849
+ {
4850
+ VMData *data;
4851
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
4852
+ // Only the latches, not the held exception. That one is handed back once and
4853
+ // gone, and the VM is usable after it, so answering true for it would tell a
4854
+ // host following the README's recycle path to throw away a healthy VM and go
4855
+ // looking for a SecureRandom stub that does not exist.
4856
+ return (data->oom_poisoned || data->handle_source_broken) ? Qtrue : Qfalse;
4857
+ }
4858
+
4859
+
2329
4860
  // JS_FreeContext + JS_FreeRuntime walk the entire heap to run finalisers.
2330
4861
  // On a VM with polyfills loaded this can be tens of milliseconds — run it
2331
4862
  // without the GVL so other Ruby threads (e.g. the next pool builder) keep
2332
4863
  // progressing. Safe to release because nothing in the teardown path calls
2333
4864
  // back into Ruby: module_loader, console, and define_function callbacks
2334
4865
  // only fire during JS execution, not during free.
4866
+ struct teardown_job
4867
+ {
4868
+ JSContext *context;
4869
+ bool std_handlers_installed;
4870
+ };
4871
+
2335
4872
  static void *vm_dispose_no_gvl(void *p)
2336
4873
  {
2337
- vm_teardown_context((JSContext *)p);
4874
+ struct teardown_job *job = (struct teardown_job *)p;
4875
+ vm_teardown_context(job->context, job->std_handlers_installed);
2338
4876
  return NULL;
2339
4877
  }
2340
4878
 
4879
+ static VALUE dispose_notify_body(VALUE p)
4880
+ {
4881
+ quickjsrb_notify_all_rejections((VMData *)p, false);
4882
+ return Qnil;
4883
+ }
4884
+
2341
4885
  static VALUE vm_m_dispose(VALUE r_self)
2342
4886
  {
2343
4887
  VMData *data;
@@ -2355,23 +4899,36 @@ static VALUE vm_m_dispose(VALUE r_self)
2355
4899
  if (data->evals_in_flight > 0)
2356
4900
  rb_raise(rb_eThreadError, "cannot dispose a Quickjs::VM while it is evaluating");
2357
4901
 
4902
+ // Jobs still queued will never run, so what is pending is unhandled. Counted
4903
+ // as an entry, so the handler cannot dispose! under it.
4904
+ if (data->pending_rejections.live > 0 && !data->oom_poisoned)
4905
+ run_held_js_entry(data, dispose_notify_body, (VALUE)data);
4906
+ rejection_list_free(data->context, &data->pending_rejections);
4907
+
2358
4908
  if (!JS_IsUndefined(data->j_file_proxy_creator))
2359
4909
  {
2360
4910
  JS_FreeValue(data->context, data->j_file_proxy_creator);
2361
4911
  data->j_file_proxy_creator = JS_UNDEFINED;
2362
4912
  }
4913
+ if (!JS_IsUndefined(data->j_blob_ctor))
4914
+ {
4915
+ JS_FreeValue(data->context, data->j_blob_ctor);
4916
+ data->j_blob_ctor = JS_UNDEFINED;
4917
+ }
2363
4918
 
2364
4919
  // Mark disposed before releasing the GVL so a concurrent dfree finds
2365
4920
  // disposed=true and skips its own teardown.
2366
4921
  data->disposed = true;
2367
4922
 
2368
- rb_thread_call_without_gvl(vm_dispose_no_gvl, data->context, NULL, NULL);
4923
+ struct teardown_job job = {data->context, data->std_handlers_installed};
4924
+ rb_thread_call_without_gvl(vm_dispose_no_gvl, &job, NULL, NULL);
2369
4925
 
2370
4926
  // Drop references to user-supplied closures so Ruby GC can reclaim them
2371
4927
  // (and anything they captured) before the wrapping VM object itself is
2372
4928
  // collected. Matters for pool-rebuild workloads that dispose eagerly.
2373
4929
  data->defined_functions = rb_hash_new();
2374
4930
  data->alive_objects = rb_hash_new();
4931
+ data->alive_handles = rb_hash_new();
2375
4932
  data->log_listener = Qnil;
2376
4933
  data->module_loader = Qnil;
2377
4934