quickjs 0.20.0 → 0.21.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 605a1b267393aa0e4e3c722d1d53859eb28a4b90ed439c84d06342badb264233
4
- data.tar.gz: a64971b38fa7cdf083ff0437c29fa4bdb72805120991ed353caa4c1630ae53df
3
+ metadata.gz: 3b261a41c198fb0494661ceee781db96bdb3a03614dfd4e5cb4361e0346979df
4
+ data.tar.gz: d22a2d8524c572dc6ba0d58a25ef2efe1474bdfed6f623b4bfe54564f6ec4fd1
5
5
  SHA512:
6
- metadata.gz: 9de98af61a9071db5755e33edb9193ae755f89b77b04960c705e54832aa078b2dd384cc3f6ed3c2b9f87f7d57114152955e17ddeb29624b016af61c2f72e2bf3
7
- data.tar.gz: 3563103113d14a14120674b1d53d330778d22494786401481af9205eae91b9d5a780554861b5c054180cef9460cbbae0a77c039602d6c35c8752e08e4dc44c88
6
+ metadata.gz: 277439c6f9183e3d7cd72d4a468384adac3ef3c5a33df9d3db6ceb86139d0dae2d2e75591cc4240159d8a3bbd04db33cb7fa077a03dd70c6a38a8227078723c1
7
+ data.tar.gz: bbb5bb21051db7337cbb14ff449bcc649b48c783fa947edb6edd0ec2cb643b8cc925259dbe771ee4d9b7ba31fb98c889ce1a289b5e9cbdf36640678697af3c03
data/README.md CHANGED
@@ -358,6 +358,8 @@ vm.eval_code('1 + 1') # raises Quickjs::RuntimeError "VM has been disposed"
358
358
  Thread.new { vm.dispose! }
359
359
  ```
360
360
 
361
+ Disposing a VM that is mid-evaluation on another thread would free the runtime out from under the running JS, so `dispose!` raises `ThreadError` while JS is executing on the VM (`eval_code`, `call`, `import`, `drain_jobs!`, `Runnable#run`) — dispose after the call returns.
362
+
361
363
  #### `Quickjs::VM#drain_jobs!`: Run pending JS jobs to completion
362
364
 
363
365
  QuickJS does not automatically drain the job queue at the end of a synchronous `eval_code` / `call`. Continuations scheduled via `Promise.resolve().then(...)` or `JS_EnqueueJob` stay pending until something explicitly runs them — `await` inside JS does, but a sync return path does not.
@@ -374,6 +376,17 @@ vm.eval_code('x') #=> 1
374
376
 
375
377
  Useful when porting JS that assumed V8's implicit-drain semantics — V8 (and therefore [mini_racer](https://github.com/rubyjs/mini_racer)) flushes pending jobs at every eval boundary, so `eval_code` already sees `.then()` continuations run by the time it returns. QuickJS doesn't. Patterns like `Promise.resolve().then(() => { ... })` and Stimulus/Hotwire callbacks that assume "the next microtask tick" silently fall through unless you call `drain_jobs!` explicitly.
376
378
 
379
+ #### Threads and parallelism
380
+
381
+ `eval_code` releases Ruby's GVL while JS runs, as long as no JS→Ruby bridge is registered on the VM (no `define_function`, `module_loader`, `on_unhandled_rejection`, and none of `FEATURE_TIMEOUT` / `POLYFILL_FILE` / `POLYFILL_CRYPTO` — `console.log` is fine). Separate VMs on separate Ruby threads then evaluate genuinely in parallel on multi-core hosts; when a bridge is registered, the GVL stays held for that VM's evals and they serialize as usual.
382
+
383
+ The rules for sharing VMs across threads:
384
+
385
+ - **One VM, one thread at a time.** A `Quickjs::VM` is not safe for concurrent use from multiple threads — QuickJS contexts have no internal locking. Handing a VM off between threads (e.g. constructing it on a warmer thread and using it on another) is fine as long as only one thread touches it at a time.
386
+ - **Create the VM on the thread that evaluates with it** when possible: QuickJS records the creating thread's stack bounds, and evaluating from a thread whose stack sits below them can trip a false stack-overflow error.
387
+ - **Register bridges before evaluating.** `define_function`, `module_loader=`, and `on_unhandled_rejection` raise `ThreadError` while a GVL-released eval is in flight (e.g. from inside an `on_log` listener) — the running JS was allowed to release the GVL precisely because no bridge existed when it started.
388
+ - **`MODULE_OS` caveat:** `os.signal` and `os.ttySetRaw` mutate process-wide state inside quickjs-libc, so don't call those two from VMs running concurrently on different threads. The common APIs (`os.sleep`, `os.setTimeout`, file I/O) only touch per-runtime state and are safe.
389
+
377
390
  ### Value Conversion
378
391
 
379
392
  | JavaScript | | Ruby | Note |
@@ -25,6 +25,8 @@ const char *native_errors[] = {
25
25
  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
+ static void check_disposed(VMData *data);
29
+ static void run_gvl_release_region(VMData *data, void *(*job_run)(void *), void *job, JSValue *j_result, void *owned_buf0, void *owned_buf1);
28
30
 
29
31
  JSValue to_js_value(JSContext *ctx, VALUE r_value);
30
32
  VALUE to_rb_value(JSContext *ctx, JSValue j_val);
@@ -871,7 +873,10 @@ static JSValue js_quickjsrb_set_timeout(JSContext *ctx, JSValueConst _this, int
871
873
  return JS_UNDEFINED;
872
874
  }
873
875
 
874
- static VALUE r_try_call_listener(VALUE r_args)
876
+ // The single way the on_log listener is invoked. Called under rb_protect by
877
+ // dispatch_log (uncaught-error headline rows) and unprotected — the caller's
878
+ // outer rb_protect covers it — by r_build_and_dispatch_log (console rows).
879
+ static VALUE r_call_log_listener(VALUE r_args)
875
880
  {
876
881
  VALUE r_listener = RARRAY_AREF(r_args, 0);
877
882
  VALUE r_log = RARRAY_AREF(r_args, 1);
@@ -886,7 +891,7 @@ static int dispatch_log(VMData *data, const char *severity, VALUE r_row)
886
891
  VALUE r_log = r_log_new(severity, r_row);
887
892
  VALUE r_args = rb_ary_new3(2, data->log_listener, r_log);
888
893
  int error;
889
- rb_protect(r_try_call_listener, r_args, &error);
894
+ rb_protect(r_call_log_listener, r_args, &error);
890
895
  return error;
891
896
  }
892
897
 
@@ -902,13 +907,30 @@ static VALUE vm_m_on_log(VALUE r_self)
902
907
  return Qnil;
903
908
  }
904
909
 
905
- static JSValue js_quickjsrb_log(JSContext *ctx, JSValueConst _this, int argc, JSValueConst *argv, const char *severity)
910
+ struct quickjsrb_log_call
906
911
  {
912
+ JSContext *ctx;
913
+ int argc;
914
+ JSValueConst *argv;
915
+ const char *severity;
916
+ JSValue result;
917
+ };
918
+
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)
926
+ {
927
+ struct quickjsrb_log_call *call = (struct quickjsrb_log_call *)r_call;
928
+ JSContext *ctx = call->ctx;
907
929
  VMData *data = JS_GetContextOpaque(ctx);
908
930
  VALUE r_row = rb_ary_new();
909
- for (int i = 0; i < argc; i++)
931
+ for (int i = 0; i < call->argc; i++)
910
932
  {
911
- JSValue j_logged = JS_DupValue(ctx, argv[i]);
933
+ JSValueConst j_logged = call->argv[i];
912
934
  VALUE r_raw;
913
935
  if (JS_VALUE_GET_NORM_TAG(j_logged) == JS_TAG_OBJECT && JS_PromiseState(ctx, j_logged) != -1)
914
936
  {
@@ -946,12 +968,22 @@ static JSValue js_quickjsrb_log(JSContext *ctx, JSValueConst _this, int argc, JS
946
968
  const char *body = JS_ToCString(ctx, j_logged);
947
969
  VALUE r_c = rb_str_new2(body);
948
970
  JS_FreeCString(ctx, body);
949
- JS_FreeValue(ctx, j_logged);
950
971
 
951
972
  rb_ary_push(r_row, r_log_body_new(r_raw, r_c));
952
973
  }
953
974
 
954
- int error = dispatch_log(data, severity, r_row);
975
+ r_call_log_listener(rb_ary_new3(2, data->log_listener, r_log_new(call->severity, r_row)));
976
+ return Qnil;
977
+ }
978
+
979
+ // Requires the GVL. A caught Ruby exception (from row building or the
980
+ // listener) becomes a JS throw, so it unwinds through QuickJS as a regular
981
+ // JS exception instead of a cross-boundary longjmp.
982
+ static JSValue js_quickjsrb_log_inner(JSContext *ctx, int argc, JSValueConst *argv, const char *severity)
983
+ {
984
+ struct quickjsrb_log_call call = {ctx, argc, argv, severity, JS_UNDEFINED};
985
+ int error;
986
+ rb_protect(r_build_and_dispatch_log, (VALUE)&call, &error);
955
987
  if (error)
956
988
  {
957
989
  VALUE r_error = rb_errinfo();
@@ -962,37 +994,102 @@ static JSValue js_quickjsrb_log(JSContext *ctx, JSValueConst _this, int argc, JS
962
994
  return JS_UNDEFINED;
963
995
  }
964
996
 
997
+ static void *quickjsrb_log_with_gvl(void *p)
998
+ {
999
+ struct quickjsrb_log_call *c = p;
1000
+ VMData *data = JS_GetContextOpaque(c->ctx);
1001
+ // The GVL is held for the duration of this callback. Clear the flag so
1002
+ // JS re-entered from the on_log listener (a bridged eval_code, call,
1003
+ // eval_bytecode, ...) routes its console.log inline — with the flag
1004
+ // still true it would call rb_thread_call_with_gvl while already holding
1005
+ // the GVL, which MRI aborts on. A plain save/restore pair suffices: the
1006
+ // listener can't longjmp out (js_quickjsrb_log_inner rb_protects
1007
+ // everything it runs).
1008
+ bool prev = data->gvl_released_js;
1009
+ data->gvl_released_js = false;
1010
+ c->result = js_quickjsrb_log_inner(c->ctx, c->argc, c->argv, c->severity);
1011
+ data->gvl_released_js = prev;
1012
+ return NULL;
1013
+ }
1014
+
1015
+ // Dispatcher: when the caller released the GVL around JS_Eval (see
1016
+ // vm_m_evalCode pure-path), Ruby APIs can't be touched directly. Re-acquire
1017
+ // the GVL via rb_thread_call_with_gvl before running the row-building body.
1018
+ // When the GVL is already held, call the body inline to avoid the re-acquire
1019
+ // overhead.
1020
+ static JSValue js_quickjsrb_log(JSContext *ctx, int argc, JSValueConst *argv, const char *severity)
1021
+ {
1022
+ VMData *data = JS_GetContextOpaque(ctx);
1023
+ // With no listener registered the built row would be discarded, so skip
1024
+ // the whole pipeline — most importantly the GVL re-acquire below, which
1025
+ // would otherwise turn every console.log of a log-heavy pure-path script
1026
+ // into a full GVL round-trip for nothing. This deliberately also skips
1027
+ // argument stringification (getters / toString / to_rb_value) and its
1028
+ // side effects or failures: console.log with no listener is a true
1029
+ // no-op. Reading a VALUE field for a Qnil comparison is a plain aligned
1030
+ // pointer-sized read, safe without the GVL; a racing on_log registration
1031
+ // (which itself requires the GVL) at worst drops this one row.
1032
+ if (NIL_P(data->log_listener))
1033
+ return JS_UNDEFINED;
1034
+ if (data->gvl_released_js)
1035
+ {
1036
+ struct quickjsrb_log_call c = {ctx, argc, argv, severity, JS_UNDEFINED};
1037
+ rb_thread_call_with_gvl(quickjsrb_log_with_gvl, &c);
1038
+ return c.result;
1039
+ }
1040
+ return js_quickjsrb_log_inner(ctx, argc, argv, severity);
1041
+ }
1042
+
965
1043
  static JSValue js_console_info(JSContext *ctx, JSValueConst this, int argc, JSValueConst *argv)
966
1044
  {
967
- return js_quickjsrb_log(ctx, this, argc, argv, "info");
1045
+ return js_quickjsrb_log(ctx, argc, argv, "info");
968
1046
  }
969
1047
 
970
1048
  static JSValue js_console_verbose(JSContext *ctx, JSValueConst this, int argc, JSValueConst *argv)
971
1049
  {
972
- return js_quickjsrb_log(ctx, this, argc, argv, "verbose");
1050
+ return js_quickjsrb_log(ctx, argc, argv, "verbose");
973
1051
  }
974
1052
 
975
1053
  static JSValue js_console_warn(JSContext *ctx, JSValueConst this, int argc, JSValueConst *argv)
976
1054
  {
977
- return js_quickjsrb_log(ctx, this, argc, argv, "warning");
1055
+ return js_quickjsrb_log(ctx, argc, argv, "warning");
978
1056
  }
979
1057
 
980
1058
  static JSValue js_console_error(JSContext *ctx, JSValueConst this, int argc, JSValueConst *argv)
981
1059
  {
982
- return js_quickjsrb_log(ctx, this, argc, argv, "error");
1060
+ return js_quickjsrb_log(ctx, argc, argv, "error");
983
1061
  }
984
1062
 
985
1063
  // Run polyfill bytecode load + eval without the GVL so a background
986
1064
  // warmer thread can populate a VM pool in parallel with the main thread
987
1065
  // on multi-core hosts.
988
1066
  //
989
- // Releasing the GVL here is only safe because the bundled polyfills are
990
- // pure JS (file / encoding / url) and no Ruby-bridged callbacks have
991
- // been registered on globalThis yet at this point in vm_m_initialize.
992
- // If the order ever changes — e.g. moving define_function setup ahead of
993
- // the polyfill loads the polyfill bytecode could re-enter Ruby without
994
- // the GVL held. Keep host callback registration after polyfill loading.
995
- struct polyfill_load_args
1067
+ // Two call sites use this helper:
1068
+ //
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.
1080
+ //
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
1083
+ // bytecode is arbitrary (registered by companion gems), so no audit
1084
+ // applies: the caller gates the release on can_eval_gvl_free() to
1085
+ // bail whenever a direct-rb_funcall bridge (File proxy, crypto, …)
1086
+ // is installed; console.log is covered by js_quickjsrb_log's
1087
+ // gvl_released_js re-acquire either way.
1088
+ //
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.
1092
+ struct bytecode_load_job
996
1093
  {
997
1094
  JSContext *ctx;
998
1095
  const uint8_t *buf;
@@ -1000,19 +1097,33 @@ struct polyfill_load_args
1000
1097
  JSValue result;
1001
1098
  };
1002
1099
 
1003
- static void *polyfill_load_no_gvl(void *p)
1100
+ // 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).
1103
+ static void *bytecode_load_job_run(void *p)
1004
1104
  {
1005
- struct polyfill_load_args *args = p;
1006
- JSValue obj = JS_ReadObject(args->ctx, args->buf, args->buf_len, JS_READ_OBJ_BYTECODE);
1007
- args->result = JS_EvalFunction(args->ctx, obj); // frees obj
1105
+ struct bytecode_load_job *job = p;
1106
+ JSValue obj = JS_ReadObject(job->ctx, job->buf, job->buf_len, JS_READ_OBJ_BYTECODE);
1107
+ // JS_EvalFunction on a JS_EXCEPTION input replaces the pending exception
1108
+ // with a generic "bytecode function expected" TypeError, losing the actual
1109
+ // deserialization diagnostic from JS_ReadObject. Short-circuit instead.
1110
+ if (JS_IsException(obj))
1111
+ {
1112
+ job->result = obj;
1113
+ return NULL;
1114
+ }
1115
+ job->result = JS_EvalFunction(job->ctx, obj); // frees obj
1008
1116
  return NULL;
1009
1117
  }
1010
1118
 
1011
- static JSValue load_polyfill_bytecode(JSContext *ctx, const uint8_t *buf, size_t buf_len)
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)
1012
1123
  {
1013
- struct polyfill_load_args args = {ctx, buf, buf_len, JS_UNDEFINED};
1014
- rb_thread_call_without_gvl(polyfill_load_no_gvl, &args, NULL, NULL);
1015
- return args.result;
1124
+ 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);
1126
+ return job.result;
1016
1127
  }
1017
1128
 
1018
1129
  static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
@@ -1070,14 +1181,18 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1070
1181
  }
1071
1182
  else if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featureTimeoutId))))
1072
1183
  {
1184
+ // setTimeout itself only enqueues, but js_delay_and_eval_job (the
1185
+ // enqueued callback) calls rb_funcall and rb_thread_wait_for
1186
+ // synchronously while js_std_await drains the queue — so this counts
1187
+ // as a Ruby bridge and eval must keep the GVL.
1073
1188
  JS_SetPropertyStr(
1074
1189
  data->context, j_global, "setTimeout",
1075
- JS_NewCFunction(data->context, js_quickjsrb_set_timeout, "setTimeout", 2));
1190
+ quickjsrb_new_ruby_bridge(data->context, js_quickjsrb_set_timeout, "setTimeout", 2));
1076
1191
  }
1077
1192
 
1078
1193
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillFileId))))
1079
1194
  {
1080
- JSValue j_polyfillFileResult = load_polyfill_bytecode(data->context, &qjsc_polyfill_file_min, qjsc_polyfill_file_min_size);
1195
+ JSValue j_polyfillFileResult = load_polyfill_bytecode(data, &qjsc_polyfill_file_min, qjsc_polyfill_file_min_size, false);
1081
1196
  JS_FreeValue(data->context, j_polyfillFileResult);
1082
1197
 
1083
1198
  quickjsrb_init_file_proxy(data);
@@ -1085,13 +1200,13 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1085
1200
 
1086
1201
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillEncodingId))))
1087
1202
  {
1088
- JSValue j_polyfillEncodingResult = load_polyfill_bytecode(data->context, &qjsc_polyfill_encoding_min, qjsc_polyfill_encoding_min_size);
1203
+ JSValue j_polyfillEncodingResult = load_polyfill_bytecode(data, &qjsc_polyfill_encoding_min, qjsc_polyfill_encoding_min_size, false);
1089
1204
  JS_FreeValue(data->context, j_polyfillEncodingResult);
1090
1205
  }
1091
1206
 
1092
1207
  if (RTEST(rb_funcall(r_features, rb_intern("include?"), 1, QUICKJSRB_SYM(featurePolyfillUrlId))))
1093
1208
  {
1094
- JSValue j_polyfillUrlResult = load_polyfill_bytecode(data->context, &qjsc_polyfill_url_min, qjsc_polyfill_url_min_size);
1209
+ JSValue j_polyfillUrlResult = load_polyfill_bytecode(data, &qjsc_polyfill_url_min, qjsc_polyfill_url_min_size, false);
1095
1210
  JS_FreeValue(data->context, j_polyfillUrlResult);
1096
1211
  }
1097
1212
 
@@ -1100,10 +1215,11 @@ static VALUE vm_m_initialize(int argc, VALUE *argv, VALUE r_self)
1100
1215
  quickjsrb_init_crypto(data->context, j_global);
1101
1216
  }
1102
1217
 
1103
- // Host callbacks (console, setTimeout, Ruby-bridged functions) are
1104
- // registered below this point after all polyfill loading above.
1105
- // load_polyfill_bytecode releases the GVL; any code moved above this
1106
- // line that touches Ruby APIs must re-acquire it first.
1218
+ // console and the remaining host callbacks are registered below this
1219
+ // point. setTimeout and the File proxy above predate the GVL-released
1220
+ // polyfill loads only under the audit described at
1221
+ // load_polyfill_bytecode re-read it before reordering this function
1222
+ // or registering anything else above the loads.
1107
1223
  JSValue j_console = JS_NewObject(data->context);
1108
1224
  JS_SetPropertyStr(
1109
1225
  data->context, j_console, "log",
@@ -1194,6 +1310,266 @@ static void arm_eval_timer(VMData *data)
1194
1310
  JS_SetInterruptHandler(JS_GetRuntime(data->context), interrupt_handler, data->eval_time);
1195
1311
  }
1196
1312
 
1313
+ // Pure-path predicate: true when no JS→Ruby bridge can fire during eval
1314
+ // other than console.log (which is handled by js_quickjsrb_log's
1315
+ // gvl_released_js re-acquire). When true, eval can safely run with the
1316
+ // GVL released so other Ruby threads make progress on different cores.
1317
+ // C-function bridges registered via quickjsrb_new_ruby_bridge (crypto.*,
1318
+ // File proxy, setTimeout) call rb_funcall directly — those would need to
1319
+ // learn the gvl_released_js pattern before they can run under a released
1320
+ // GVL, so we hold the GVL when any of them is installed.
1321
+ //
1322
+ // :feature_std / :feature_os intentionally pass: quickjs-libc never calls
1323
+ // into Ruby, and its state is almost entirely per-runtime (JSThreadState).
1324
+ // The exceptions are two file-scope globals reachable from niche APIs —
1325
+ // `oldtty` (os.ttySetRaw) and `os_pending_signals` (os.signal) — which can
1326
+ // race when multiple VMs run those APIs concurrently. Gating the whole
1327
+ // feature would re-serialize os.sleep / os.setTimeout across threads, so
1328
+ // the constraint is documented in the README instead.
1329
+ static bool can_eval_gvl_free(VMData *data)
1330
+ {
1331
+ return RHASH_SIZE(data->defined_functions) == 0
1332
+ && NIL_P(data->module_loader)
1333
+ && NIL_P(data->on_unhandled_rejection)
1334
+ && !data->has_native_ruby_bridge;
1335
+ }
1336
+
1337
+ struct eval_code_job
1338
+ {
1339
+ JSContext *ctx;
1340
+ const char *code;
1341
+ size_t code_len;
1342
+ const char *filename;
1343
+ bool async_mode;
1344
+ JSValue result;
1345
+ };
1346
+
1347
+ // Shared eval core: JS_Eval (+ js_std_await and the {value, done} unwrap for
1348
+ // async). Pure C over JSValues — MUST NOT touch the Ruby VM, because the
1349
+ // pure path runs it with the GVL released (see js_quickjsrb_log's dispatcher
1350
+ // for how console.log re-acquires). The bridged path calls it directly with
1351
+ // the GVL held; both paths share this body so a future fix to the eval
1352
+ // sequence can't silently diverge between them.
1353
+ static void *eval_code_job_run(void *p)
1354
+ {
1355
+ struct eval_code_job *job = p;
1356
+ int eval_flags = job->async_mode ? (JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC) : JS_EVAL_TYPE_GLOBAL;
1357
+ JSValue j_codeResult = JS_Eval(job->ctx, job->code, job->code_len, job->filename, eval_flags);
1358
+ if (job->async_mode)
1359
+ {
1360
+ JSValue j_awaitedResult = js_std_await(job->ctx, j_codeResult); // frees j_codeResult
1361
+ job->result = JS_GetPropertyStr(job->ctx, j_awaitedResult, "value");
1362
+ JS_FreeValue(job->ctx, j_awaitedResult);
1363
+ }
1364
+ else
1365
+ {
1366
+ job->result = j_codeResult;
1367
+ }
1368
+ return NULL;
1369
+ }
1370
+
1371
+ // A GVL-release region — the one place that owns the handshake required to
1372
+ // run a pure-C QuickJS job with the GVL released: the save/restore of
1373
+ // gvl_released_js (restore, not clear, so a region nested through an
1374
+ // on_log listener doesn't flip the outer region's console.log back to the
1375
+ // inline path), the evals_in_flight increment that makes dispose! refuse,
1376
+ // the stale-dispose re-check, and an rb_ensure cleanup that keeps all of
1377
+ // it balanced when an async interrupt (Thread#raise / Thread#kill /
1378
+ // Timeout) fires in rb_thread_call_without_gvl's GVL-re-acquire epilogue.
1379
+ struct gvl_release_region
1380
+ {
1381
+ VMData *data;
1382
+ void *(*job_run)(void *); // pure C over JSValues — must not touch Ruby
1383
+ void *job;
1384
+ // Where the job stores its JSValue result; the cleanup frees it when an
1385
+ // interrupt lands inside the region (no-op for JS_UNDEFINED).
1386
+ JSValue *j_result;
1387
+ // malloc'd inputs owned by the region so they survive an interrupt
1388
+ // unwinding past the caller's own frees; NULL slots are fine.
1389
+ void *owned_bufs[2];
1390
+ bool prev_gvl_released;
1391
+ bool completed;
1392
+ };
1393
+
1394
+ static VALUE gvl_release_region_run(VALUE p)
1395
+ {
1396
+ struct gvl_release_region *region = (struct gvl_release_region *)p;
1397
+ rb_thread_call_without_gvl(region->job_run, region->job, NULL, NULL);
1398
+ region->completed = true;
1399
+ return Qnil;
1400
+ }
1401
+
1402
+ static VALUE gvl_release_region_cleanup(VALUE p)
1403
+ {
1404
+ struct gvl_release_region *region = (struct gvl_release_region *)p;
1405
+ VMData *data = region->data;
1406
+ data->gvl_released_js = region->prev_gvl_released;
1407
+ data->evals_in_flight--;
1408
+ data->gvl_release_regions--;
1409
+ free(region->owned_bufs[0]);
1410
+ free(region->owned_bufs[1]);
1411
+ // 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.
1415
+ if (!region->completed)
1416
+ JS_FreeValue(data->context, *region->j_result);
1417
+ return Qnil;
1418
+ }
1419
+
1420
+ // Run job_run(job) with the GVL released. owned_buf0/1 are malloc'd
1421
+ // buffers backing the job's inputs; ownership transfers to the region,
1422
+ // which frees them on every exit path — including the disposed bail-out
1423
+ // below and async-interrupt unwinds. The caller's check_disposed may be
1424
+ // stale by now (argument parsing can yield the GVL to a concurrent
1425
+ // dispose!), so re-check here: nothing between this check and the release
1426
+ // yields, and dispose! refuses while evals_in_flight > 0, so the two
1427
+ // sides can't miss each other.
1428
+ static void run_gvl_release_region(VMData *data, void *(*job_run)(void *), void *job, JSValue *j_result, void *owned_buf0, void *owned_buf1)
1429
+ {
1430
+ if (data->disposed)
1431
+ {
1432
+ free(owned_buf0);
1433
+ free(owned_buf1);
1434
+ check_disposed(data); // raises
1435
+ }
1436
+
1437
+ struct gvl_release_region region = {
1438
+ .data = data,
1439
+ .job_run = job_run,
1440
+ .job = job,
1441
+ .j_result = j_result,
1442
+ .owned_bufs = {owned_buf0, owned_buf1},
1443
+ .prev_gvl_released = data->gvl_released_js,
1444
+ .completed = false,
1445
+ };
1446
+
1447
+ data->evals_in_flight++;
1448
+ data->gvl_release_regions++;
1449
+ data->gvl_released_js = true;
1450
+ rb_ensure(gvl_release_region_run, (VALUE)&region, gvl_release_region_cleanup, (VALUE)&region);
1451
+ }
1452
+
1453
+ // Installing a JS→Ruby bridge (define_function, module_loader=,
1454
+ // on_unhandled_rejection) invalidates the can_eval_gvl_free decision an
1455
+ // in-flight GVL-released eval was started under: after e.g. an on_log
1456
+ // listener returns, the still-running JS could reach the new bridge and
1457
+ // call Ruby APIs without holding the GVL. Refuse loudly — register
1458
+ // bridges before evaluating. Registration during GVL-held evals stays
1459
+ // allowed, as it always was: gvl_release_regions only counts released
1460
+ // regions.
1461
+ static void check_no_gvl_release_in_flight(VMData *data)
1462
+ {
1463
+ if (data->gvl_release_regions > 0)
1464
+ rb_raise(rb_eThreadError, "cannot install a JS-to-Ruby bridge on a Quickjs::VM while it is evaluating with the GVL released");
1465
+ }
1466
+
1467
+ static VALUE evals_in_flight_release(VALUE p)
1468
+ {
1469
+ ((VMData *)p)->evals_in_flight--;
1470
+ return Qnil;
1471
+ }
1472
+
1473
+ // Counterpart of run_gvl_release_region for the GVL-held entry points:
1474
+ // bridge callbacks (define_function procs, setTimeout's rb_thread_wait_for,
1475
+ // on_log listeners) yield the GVL mid-execution, so every JS execution must
1476
+ // elevate evals_in_flight for dispose! to refuse — and the decrement must
1477
+ // survive every raise exit: JS exceptions, type errors, conversion
1478
+ // failures, and async interrupts (Thread#raise / Timeout) delivered inside
1479
+ // a bridge callback. A stranded counter would make dispose! refuse forever,
1480
+ // so the check + increment + rb_ensure discipline lives here structurally
1481
+ // rather than being repeated at each call site. Entry points may have run
1482
+ // argument parsing that yields the GVL since their own entry check, hence
1483
+ // the disposed re-check immediately before counting. (The interrupt longjmp
1484
+ // still rips through QuickJS frames — a pre-existing hazard; whether the
1485
+ // bridge should rb_protect and convert instead is a semantics decision
1486
+ // deferred in the #56 review.)
1487
+ static VALUE run_held_js_entry(VMData *data, VALUE (*body)(VALUE), VALUE arg)
1488
+ {
1489
+ check_disposed(data);
1490
+ data->evals_in_flight++;
1491
+ return rb_ensure(body, arg, evals_in_flight_release, (VALUE)data);
1492
+ }
1493
+
1494
+ static VALUE eval_code_job_run_body(VALUE p)
1495
+ {
1496
+ eval_code_job_run((struct eval_code_job *)p);
1497
+ return Qnil;
1498
+ }
1499
+
1500
+ // rb_ensure bodies over the shared bytecode core, for the GVL-held call
1501
+ // sites: vm_m_loadPolyfillBytecode's held fallback loads without awaiting
1502
+ // (matching its release path), vm_m_evalBytecode awaits the result. These
1503
+ // can take RSTRING_PTR without a copy: JS_ReadObject consumes the buffer
1504
+ // before any bridge can fire, so the GVL is provably held (no compaction)
1505
+ // for as long as the pointer is read.
1506
+ static VALUE bytecode_load_body(VALUE p)
1507
+ {
1508
+ bytecode_load_job_run((struct bytecode_load_job *)p);
1509
+ return Qnil;
1510
+ }
1511
+
1512
+ static VALUE bytecode_eval_await_body(VALUE p)
1513
+ {
1514
+ 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;
1520
+ }
1521
+
1522
+ // Copy a Ruby String to a malloc'd buffer that outlives a GVL release —
1523
+ // RSTRING_PTR is GC-movable, so a released region must not read the
1524
+ // String's own storage. Ownership passes to the caller (the release
1525
+ // regions free it on every exit path). nul_terminate preserves the
1526
+ // sentinel NUL QuickJS's parser reads past the length: Ruby strings carry
1527
+ // one past RSTRING_LEN, so GVL-held paths get it for free, and a copy
1528
+ // must add it back. Allocates at least one byte either way — malloc(0)
1529
+ // may return NULL on success, and an empty String (e.g. empty bytecode)
1530
+ // must still reach the JS-level diagnostic instead of a spurious
1531
+ // NoMemError.
1532
+ static char *copy_rstring_to_owned_buffer(VALUE r_str, size_t *out_len, bool nul_terminate)
1533
+ {
1534
+ size_t len = (size_t)RSTRING_LEN(r_str);
1535
+ char *buf = malloc(nul_terminate ? len + 1 : (len > 0 ? len : 1));
1536
+ if (buf == NULL)
1537
+ rb_raise(rb_eNoMemError, "failed to allocate a GVL-release copy of a Ruby String");
1538
+ memcpy(buf, RSTRING_PTR(r_str), len);
1539
+ if (nul_terminate)
1540
+ buf[len] = '\0';
1541
+ *out_len = len;
1542
+ return buf;
1543
+ }
1544
+
1545
+ // Run the eval core without the GVL. Inputs are copied to malloc'd buffers
1546
+ // because RSTRING_PTR can be invalidated by GC compaction while we're
1547
+ // released.
1548
+ static VALUE eval_code_release_gvl(VMData *data, VALUE r_code, const char *filename, bool async_mode)
1549
+ {
1550
+ size_t code_len;
1551
+ char *code_buf = copy_rstring_to_owned_buffer(r_code, &code_len, true);
1552
+
1553
+ char *filename_buf = strdup(filename);
1554
+ if (filename_buf == NULL)
1555
+ {
1556
+ free(code_buf);
1557
+ rb_raise(rb_eNoMemError, "failed to allocate eval filename buffer");
1558
+ }
1559
+
1560
+ struct eval_code_job job = {
1561
+ .ctx = data->context,
1562
+ .code = code_buf,
1563
+ .code_len = code_len,
1564
+ .filename = filename_buf,
1565
+ .async_mode = async_mode,
1566
+ .result = JS_UNDEFINED,
1567
+ };
1568
+ run_gvl_release_region(data, eval_code_job_run, &job, &job.result, code_buf, filename_buf);
1569
+
1570
+ return to_rb_return_value(data->context, job.result);
1571
+ }
1572
+
1197
1573
  static VALUE vm_m_evalCode(int argc, VALUE *argv, VALUE r_self)
1198
1574
  {
1199
1575
  VMData *data;
@@ -1218,19 +1594,23 @@ static VALUE vm_m_evalCode(int argc, VALUE *argv, VALUE r_self)
1218
1594
 
1219
1595
  StringValue(r_code);
1220
1596
 
1221
- if (!async_mode)
1222
- {
1223
- JSValue j_codeResult = JS_Eval(data->context, RSTRING_PTR(r_code), RSTRING_LEN(r_code), filename, JS_EVAL_TYPE_GLOBAL);
1224
- return to_rb_return_value(data->context, j_codeResult);
1225
- }
1226
-
1227
- JSValue j_codeResult = JS_Eval(data->context, RSTRING_PTR(r_code), RSTRING_LEN(r_code), filename, JS_EVAL_TYPE_GLOBAL | JS_EVAL_FLAG_ASYNC);
1228
- JSValue j_awaitedResult = js_std_await(data->context, j_codeResult); // This frees j_codeResult
1229
- // JS_EVAL_FLAG_ASYNC wraps the result in {value, done} — extract the actual value
1230
- // Free j_awaitedResult before to_rb_return_value because it may raise (longjmp), which would skip cleanup
1231
- JSValue j_returnedValue = JS_GetPropertyStr(data->context, j_awaitedResult, "value");
1232
- JS_FreeValue(data->context, j_awaitedResult);
1233
- return to_rb_return_value(data->context, j_returnedValue);
1597
+ if (can_eval_gvl_free(data))
1598
+ return eval_code_release_gvl(data, r_code, filename, async_mode);
1599
+
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,
1611
+ };
1612
+ run_held_js_entry(data, eval_code_job_run_body, (VALUE)&job);
1613
+ return to_rb_return_value(data->context, job.result);
1234
1614
  }
1235
1615
 
1236
1616
  static VALUE vm_m_compile(int argc, VALUE *argv, VALUE r_self)
@@ -1275,6 +1655,7 @@ static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
1275
1655
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1276
1656
 
1277
1657
  check_disposed(data);
1658
+ check_oom_poisoned(data);
1278
1659
 
1279
1660
  if (!RB_TYPE_P(r_bytecode, T_STRING))
1280
1661
  {
@@ -1286,22 +1667,21 @@ static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
1286
1667
 
1287
1668
  arm_eval_timer(data);
1288
1669
 
1289
- // GVL intentionally held here: user bytecode may invoke Ruby-bridged
1290
- // callbacks registered via define_function, which call Ruby APIs.
1291
- // Unlike polyfill_load_no_gvl, this path cannot release the GVL safely.
1292
- JSValue j_func = JS_ReadObject(data->context,
1293
- (const uint8_t *)RSTRING_PTR(r_bytecode),
1294
- (size_t)RSTRING_LEN(r_bytecode),
1295
- JS_READ_OBJ_BYTECODE);
1296
- if (JS_IsException(j_func))
1297
- {
1298
- return to_rb_value(data->context, j_func); // raises
1299
- }
1300
-
1301
- JSValue j_codeResult = JS_EvalFunction(data->context, j_func); // frees j_func
1302
- JSValue j_awaitedResult = js_std_await(data->context, j_codeResult);
1303
- JSValue j_returnedValue = JS_GetPropertyStr(data->context, j_awaitedResult, "value");
1304
- JS_FreeValue(data->context, j_awaitedResult);
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
1682
+
1683
+ JSValue j_returnedValue = JS_GetPropertyStr(data->context, job.result, "value");
1684
+ JS_FreeValue(data->context, job.result);
1305
1685
  return to_rb_return_value(data->context, j_returnedValue);
1306
1686
  }
1307
1687
 
@@ -1309,29 +1689,87 @@ static VALUE vm_m_evalBytecode(VALUE r_self, VALUE r_bytecode)
1309
1689
  // The user's `timeout_msec` is a budget for *their* code; running a
1310
1690
  // multi-MB polyfill bundle (e.g. the companion `quickjs-polyfill-intl`
1311
1691
  // gem registered via Quickjs.register_polyfill) under that budget would
1312
- // interrupt the load on tight defaults. Unlike load_polyfill_bytecode
1313
- // above we hold the GVL through JS_ReadObject + JS_EvalFunction: the
1314
- // bytecode buffer is a Ruby String, so releasing would let GC compact
1315
- // the backing storage out from under us. The static-symbol path can
1316
- // release safely; this path cannot.
1692
+ // interrupt the load on tight defaults.
1693
+ //
1694
+ // Picks the GVL-released or GVL-held path based on can_eval_gvl_free
1695
+ // (same gate vm_m_evalCode uses): if POLYFILL_FILE / POLYFILL_CRYPTO is
1696
+ // enabled, the polyfill's top-level code could reach a Ruby-bridged C
1697
+ // function that calls rb_funcall directly — those bridges don't honor
1698
+ // gvl_released_js, so we must keep the GVL. Otherwise, csim and similar
1699
+ // warmer-thread VM pools recover multi-core scaling: without the release
1700
+ // every warmer's polyfill load serializes through the GVL, collapsing
1701
+ // 4-way warmer parallelism to 1.
1702
+ //
1703
+ // The GVL-released path copies the Ruby String to a malloc'd buffer
1704
+ // because RSTRING_PTR can be invalidated by GC compaction while the GVL
1705
+ // is released. The memcpy cost is microseconds vs hundreds of ms of
1706
+ // bytecode eval, so it's negligible.
1317
1707
  static VALUE vm_m_loadPolyfillBytecode(VALUE r_self, VALUE r_bytecode)
1318
1708
  {
1319
1709
  VMData *data;
1320
1710
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1321
1711
 
1322
- check_disposed(data);
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.
1323
1715
  StringValue(r_bytecode);
1324
1716
 
1325
- JSValue j_func = JS_ReadObject(data->context,
1326
- (const uint8_t *)RSTRING_PTR(r_bytecode),
1327
- (size_t)RSTRING_LEN(r_bytecode),
1328
- JS_READ_OBJ_BYTECODE);
1329
- if (JS_IsException(j_func))
1330
- return to_rb_value(data->context, j_func); // raises
1717
+ check_disposed(data);
1718
+ check_oom_poisoned(data);
1719
+
1720
+ // "Unbudgeted" needs enforcing, not just skipping arm_eval_timer: the
1721
+ // interrupt handler installed by a previous eval stays armed with that
1722
+ // eval's started_at, so a load after the budget lapsed would be
1723
+ // interrupted on its first check — and because the bytecode is
1724
+ // async-wrapped, the interruption surfaces as a rejected promise the
1725
+ // old code never looked at: the load "succeeded" with the polyfill
1726
+ // silently missing. Disarm for the load; the next eval re-arms. Only
1727
+ // when this load is the outermost JS activity, though: a load issued
1728
+ // from inside a bridge callback (e.g. an on_log listener) must stay
1729
+ // under the in-flight eval's budget, not erase it.
1730
+ if (data->evals_in_flight == 0)
1731
+ JS_SetInterruptHandler(JS_GetRuntime(data->context), NULL, NULL);
1732
+
1733
+ size_t buf_len = (size_t)RSTRING_LEN(r_bytecode);
1734
+ JSValue j_result;
1735
+ if (can_eval_gvl_free(data))
1736
+ {
1737
+ uint8_t *buf = (uint8_t *)copy_rstring_to_owned_buffer(r_bytecode, &buf_len, false);
1738
+ j_result = load_polyfill_bytecode(data, buf, buf_len, true);
1739
+ }
1740
+ else
1741
+ {
1742
+ // This branch runs exactly when a Ruby bridge (crypto, File, …) is
1743
+ // installed, and every rb_funcall inside one is an interrupt
1744
+ // checkpoint — run_held_js_entry keeps the counter unwind (and the
1745
+ // stale-dispose re-check the released branch gets from its region)
1746
+ // on this path too.
1747
+ struct bytecode_load_job job = {data->context,
1748
+ (const uint8_t *)RSTRING_PTR(r_bytecode),
1749
+ buf_len,
1750
+ JS_UNDEFINED};
1751
+ run_held_js_entry(data, bytecode_load_body, (VALUE)&job);
1752
+ j_result = job.result;
1753
+ }
1331
1754
 
1332
- JSValue j_result = JS_EvalFunction(data->context, j_func); // frees j_func
1333
1755
  if (JS_IsException(j_result))
1334
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
+
1335
1773
  JS_FreeValue(data->context, j_result);
1336
1774
  return Qnil;
1337
1775
  }
@@ -1349,6 +1787,7 @@ static VALUE vm_m_defineGlobalFunction(int argc, VALUE *argv, VALUE r_self)
1349
1787
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1350
1788
 
1351
1789
  check_disposed(data);
1790
+ check_no_gvl_release_in_flight(data);
1352
1791
 
1353
1792
  if (RB_TYPE_P(r_name, T_ARRAY))
1354
1793
  {
@@ -1393,6 +1832,10 @@ static VALUE vm_m_defineGlobalFunction(int argc, VALUE *argv, VALUE r_self)
1393
1832
  {
1394
1833
  VALUE r_first_str = rb_funcall(RARRAY_AREF(r_name, 0), rb_intern("to_s"), 0);
1395
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".
1838
+ arm_eval_timer(data);
1396
1839
  j_parent = JS_Eval(data->context, first_seg, strlen(first_seg), vmInternalFilename, JS_EVAL_TYPE_GLOBAL);
1397
1840
 
1398
1841
  if (JS_IsException(j_parent) || !JS_IsObject(j_parent))
@@ -1460,18 +1903,20 @@ static VALUE vm_m_defineGlobalFunction(int argc, VALUE *argv, VALUE r_self)
1460
1903
  }
1461
1904
  }
1462
1905
 
1463
- static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
1906
+ struct js_entry_call
1464
1907
  {
1465
- if (argc < 1)
1466
- rb_raise(rb_eArgError, "wrong number of arguments (given 0, expected 1+)");
1467
-
1468
- VALUE r_name = argv[0];
1469
-
1908
+ int argc;
1909
+ VALUE *argv;
1470
1910
  VMData *data;
1471
- TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1911
+ };
1472
1912
 
1473
- check_disposed(data);
1474
- check_oom_poisoned(data);
1913
+ static VALUE call_global_function_body(VALUE p)
1914
+ {
1915
+ struct js_entry_call *call = (struct js_entry_call *)p;
1916
+ int argc = call->argc;
1917
+ VALUE *argv = call->argv;
1918
+ VMData *data = call->data;
1919
+ VALUE r_name = argv[0];
1475
1920
 
1476
1921
  JSValue j_this = JS_UNDEFINED;
1477
1922
  JSValue j_func;
@@ -1599,6 +2044,26 @@ static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
1599
2044
  return to_rb_return_value(data->context, js_std_await(data->context, j_result));
1600
2045
  }
1601
2046
 
2047
+ static VALUE vm_m_callGlobalFunction(int argc, VALUE *argv, VALUE r_self)
2048
+ {
2049
+ if (argc < 1)
2050
+ rb_raise(rb_eArgError, "wrong number of arguments (given 0, expected 1+)");
2051
+
2052
+ VMData *data;
2053
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2054
+
2055
+ check_disposed(data);
2056
+ check_oom_poisoned(data);
2057
+
2058
+ // evals_in_flight stays elevated for the whole call, not just the
2059
+ // JS_Eval / JS_Call moments: the body holds live JSValues across Ruby
2060
+ // calls that can yield the GVL (path-segment to_s, argument
2061
+ // conversion), and a concurrent dispose! landing in such a gap would
2062
+ // free the runtime out from under them.
2063
+ struct js_entry_call call = {argc, argv, data};
2064
+ return run_held_js_entry(data, call_global_function_body, (VALUE)&call);
2065
+ }
2066
+
1602
2067
  static VALUE vm_m_set_module_loader(VALUE r_self, VALUE r_loader)
1603
2068
  {
1604
2069
  VMData *data;
@@ -1607,6 +2072,7 @@ static VALUE vm_m_set_module_loader(VALUE r_self, VALUE r_loader)
1607
2072
  if (!NIL_P(r_loader) && !rb_obj_is_kind_of(r_loader, rb_cProc))
1608
2073
  rb_raise(rb_eTypeError, "module_loader must be a Proc or nil");
1609
2074
 
2075
+ check_no_gvl_release_in_flight(data);
1610
2076
  data->module_loader = r_loader;
1611
2077
  // Stale entries from the previous loader's policy would survive the
1612
2078
  // swap and silently shadow the new behavior.
@@ -1630,12 +2096,18 @@ static VALUE vm_m_on_unhandled_rejection(VALUE r_self)
1630
2096
  VMData *data;
1631
2097
  TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1632
2098
 
2099
+ check_no_gvl_release_in_flight(data);
1633
2100
  data->on_unhandled_rejection = rb_block_proc();
1634
2101
  return Qnil;
1635
2102
  }
1636
2103
 
1637
- static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
2104
+ static VALUE import_body(VALUE p)
1638
2105
  {
2106
+ struct js_entry_call *call = (struct js_entry_call *)p;
2107
+ int argc = call->argc;
2108
+ VALUE *argv = call->argv;
2109
+ VMData *data = call->data;
2110
+
1639
2111
  VALUE r_import_string, r_opts;
1640
2112
  rb_scan_args(argc, argv, "10:", &r_import_string, &r_opts);
1641
2113
  if (NIL_P(r_opts))
@@ -1652,11 +2124,6 @@ static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
1652
2124
  rb_raise(rb_eArgError, "pass either from: (inline source) or filename: (loader-resolved), not both");
1653
2125
  VALUE r_custom_exposure = rb_hash_aref(r_opts, ID2SYM(rb_intern("code_to_expose")));
1654
2126
 
1655
- VMData *data;
1656
- TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1657
-
1658
- check_disposed(data);
1659
-
1660
2127
  char *filename;
1661
2128
  VALUE r_seeded_key = Qnil;
1662
2129
  if (!NIL_P(r_filename))
@@ -1732,6 +2199,29 @@ static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
1732
2199
  return Qtrue;
1733
2200
  }
1734
2201
 
2202
+ static VALUE vm_m_import(int argc, VALUE *argv, VALUE r_self)
2203
+ {
2204
+ VMData *data;
2205
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2206
+
2207
+ check_disposed(data);
2208
+ check_oom_poisoned(data);
2209
+
2210
+ // Module top-level code is user JS like any eval — budget it. Without
2211
+ // this, import ran under whatever handler the previous entry point
2212
+ // left behind: a lapsed armed one (spurious interrupt) or none at all
2213
+ // after a polyfill load's disarm (unbounded).
2214
+ arm_eval_timer(data);
2215
+
2216
+ // Like vm_m_callGlobalFunction: the body registers a module in the
2217
+ // context and then runs Ruby code (_build_import, StringValueCStr on
2218
+ // user values) that can yield the GVL before the bridge eval — keep
2219
+ // evals_in_flight elevated for the whole call so a concurrent dispose!
2220
+ // can't free the runtime in those gaps.
2221
+ struct js_entry_call call = {argc, argv, data};
2222
+ return run_held_js_entry(data, import_body, (VALUE)&call);
2223
+ }
2224
+
1735
2225
  RUBY_FUNC_EXPORTED void Init_quickjsrb(void)
1736
2226
  {
1737
2227
  rb_require("json");
@@ -1796,19 +2286,10 @@ static VALUE vm_m_runGC(VALUE r_self)
1796
2286
  return Qnil;
1797
2287
  }
1798
2288
 
1799
- static VALUE vm_m_drainJobs(VALUE r_self)
2289
+ static VALUE drain_jobs_body(VALUE p)
1800
2290
  {
1801
- VMData *data;
1802
- TypedData_Get_Struct(r_self, VMData, &vm_type, data);
1803
-
1804
- check_oom_poisoned(data);
1805
-
2291
+ VMData *data = (VMData *)p;
1806
2292
  JSRuntime *runtime = JS_GetRuntime(data->context);
1807
- if (!JS_IsJobPending(runtime))
1808
- return INT2NUM(0);
1809
-
1810
- arm_eval_timer(data);
1811
-
1812
2293
  int executed = 0;
1813
2294
  for (;;)
1814
2295
  {
@@ -1822,6 +2303,22 @@ static VALUE vm_m_drainJobs(VALUE r_self)
1822
2303
  return INT2NUM(executed);
1823
2304
  }
1824
2305
 
2306
+ static VALUE vm_m_drainJobs(VALUE r_self)
2307
+ {
2308
+ VMData *data;
2309
+ TypedData_Get_Struct(r_self, VMData, &vm_type, data);
2310
+
2311
+ check_disposed(data);
2312
+ check_oom_poisoned(data);
2313
+
2314
+ if (!JS_IsJobPending(JS_GetRuntime(data->context)))
2315
+ return INT2NUM(0);
2316
+
2317
+ arm_eval_timer(data);
2318
+
2319
+ return run_held_js_entry(data, drain_jobs_body, (VALUE)data);
2320
+ }
2321
+
1825
2322
  static VALUE vm_m_memoryPoisoned(VALUE r_self)
1826
2323
  {
1827
2324
  VMData *data;
@@ -1849,6 +2346,15 @@ static VALUE vm_m_dispose(VALUE r_self)
1849
2346
  if (data->disposed)
1850
2347
  return Qnil;
1851
2348
 
2349
+ // Freeing the runtime under live JS is a use-after-free. The overlap is
2350
+ // reachable both through the GVL release (pure-path evals) and through
2351
+ // GVL-yielding bridge callbacks (setTimeout's rb_thread_wait_for,
2352
+ // define_function procs, on_log listeners) — including the README's
2353
+ // `Thread.new { vm.dispose! }` pattern and a listener calling dispose!
2354
+ // mid-eval. Fail loudly instead of corrupting the heap.
2355
+ if (data->evals_in_flight > 0)
2356
+ rb_raise(rb_eThreadError, "cannot dispose a Quickjs::VM while it is evaluating");
2357
+
1852
2358
  if (!JS_IsUndefined(data->j_file_proxy_creator))
1853
2359
  {
1854
2360
  JS_FreeValue(data->context, data->j_file_proxy_creator);
@@ -75,8 +75,60 @@ typedef struct VMData
75
75
  // enough pressure to collect the wrapper. Doubles as a double-free guard
76
76
  // for the dfree handler.
77
77
  bool disposed;
78
+ // Set while JS is executing with the GVL released so JS→Ruby bridges
79
+ // (currently js_quickjsrb_log) can re-acquire the GVL before touching
80
+ // Ruby APIs. Covers both vm_m_evalCode and vm_m_loadPolyfillBytecode;
81
+ // reset before to_rb_return_value or other Ruby-touching code runs.
82
+ // Saved/restored (not blindly cleared) so a region nested through an
83
+ // on_log listener doesn't clear the outer region's flag.
84
+ bool gvl_released_js;
85
+ // Number of JS executions currently in flight on this VM — eval_code
86
+ // (both the GVL-released and GVL-held paths), call, bytecode runs,
87
+ // polyfill bytecode loads, import, and job drains. vm_m_dispose refuses
88
+ // (ThreadError) while nonzero: freeing the runtime under live JS is a
89
+ // use-after-free, and both the GVL release and GVL-yielding bridge
90
+ // callbacks (setTimeout's rb_thread_wait_for, define_function procs,
91
+ // on_log listeners) make that overlap reachable — e.g. the README's
92
+ // `Thread.new { vm.dispose! }` pattern, or a listener calling dispose!
93
+ // mid-eval. Only mutated while holding the GVL, so plain int accesses
94
+ // are race-free.
95
+ int evals_in_flight;
96
+ // Number of GVL-release regions currently open on this VM (a subset of
97
+ // evals_in_flight). Bridge-registration APIs (define_function,
98
+ // module_loader=, on_unhandled_rejection) refuse (ThreadError) while
99
+ // nonzero: the running JS was allowed to release the GVL because
100
+ // can_eval_gvl_free held at eval start, and installing a bridge
101
+ // mid-flight — e.g. from an on_log listener, whose callback runs with
102
+ // the GVL re-acquired — would hand the still-released JS a path into
103
+ // Ruby APIs without the GVL. Only mutated while holding the GVL.
104
+ int gvl_release_regions;
105
+ // Latched by quickjsrb_new_ruby_bridge whenever a C function that calls
106
+ // into Ruby synchronously (rb_funcall & friends) WITHOUT honoring
107
+ // gvl_released_js is installed into this context. While true,
108
+ // can_eval_gvl_free fails and eval keeps the GVL held. Register every
109
+ // such function through that helper — a bridge registered with plain
110
+ // JS_NewCFunction would run against Ruby under a released GVL and
111
+ // silently corrupt the interpreter.
112
+ bool has_native_ruby_bridge;
78
113
  } VMData;
79
114
 
115
+ // Drop-in replacement for JS_NewCFunction for C functions that call into
116
+ // Ruby synchronously without honoring gvl_released_js (crypto.*, File
117
+ // proxy helpers, setTimeout's job). Latching has_native_ruby_bridge here
118
+ // makes the "keep the GVL held" contract structural instead of relying on
119
+ // each feature-init site to remember a flag assignment. Requires
120
+ // JS_SetContextOpaque(ctx, data) to have run (vm_m_initialize does this
121
+ // before any feature setup). console.log intentionally does NOT go through
122
+ // this: js_quickjsrb_log re-acquires the GVL itself, and Proc-backed
123
+ // bridges (define_function / module_loader / on_unhandled_rejection) are
124
+ // excluded structurally by can_eval_gvl_free's own checks.
125
+ static inline JSValue quickjsrb_new_ruby_bridge(JSContext *ctx, JSCFunction *func, const char *name, int length)
126
+ {
127
+ VMData *data = JS_GetContextOpaque(ctx);
128
+ data->has_native_ruby_bridge = true;
129
+ return JS_NewCFunction(ctx, func, name, length);
130
+ }
131
+
80
132
  static void vm_teardown_context(JSContext *ctx)
81
133
  {
82
134
  JSRuntime *runtime = JS_GetRuntime(ctx);
@@ -168,6 +220,10 @@ static VALUE vm_alloc(VALUE r_self)
168
220
  data->j_file_proxy_creator = JS_UNDEFINED;
169
221
  data->oom_poisoned = false;
170
222
  data->disposed = false;
223
+ data->gvl_released_js = false;
224
+ data->evals_in_flight = 0;
225
+ data->gvl_release_regions = 0;
226
+ data->has_native_ruby_bridge = false;
171
227
 
172
228
  EvalTime *eval_time = malloc(sizeof(EvalTime));
173
229
  data->eval_time = eval_time;
@@ -63,9 +63,9 @@ void quickjsrb_init_crypto(JSContext *ctx, JSValue j_global)
63
63
  {
64
64
  JSValue j_crypto = JS_NewObject(ctx);
65
65
  JS_SetPropertyStr(ctx, j_crypto, "getRandomValues",
66
- JS_NewCFunction(ctx, js_crypto_get_random_values, "getRandomValues", 1));
66
+ quickjsrb_new_ruby_bridge(ctx, js_crypto_get_random_values, "getRandomValues", 1));
67
67
  JS_SetPropertyStr(ctx, j_crypto, "randomUUID",
68
- JS_NewCFunction(ctx, js_crypto_random_uuid, "randomUUID", 0));
68
+ quickjsrb_new_ruby_bridge(ctx, js_crypto_random_uuid, "randomUUID", 0));
69
69
  quickjsrb_init_crypto_subtle(ctx, j_crypto);
70
70
  JS_SetPropertyStr(ctx, j_global, "crypto", j_crypto);
71
71
  }
@@ -65,6 +65,7 @@ static VALUE js_usages_to_ruby_array(JSContext *ctx, JSValueConst j_usages)
65
65
  static void js_reject_with_ruby_error(JSContext *ctx, JSValueConst *resolving_funcs)
66
66
  {
67
67
  VALUE r_error = rb_errinfo();
68
+ rb_set_errinfo(Qnil);
68
69
  VALUE r_message = rb_funcall(r_error, rb_intern("message"), 0);
69
70
  JSValue j_err = JS_NewError(ctx);
70
71
  JS_SetPropertyStr(ctx, j_err, "message", JS_NewString(ctx, StringValueCStr(r_message)));
@@ -974,28 +975,28 @@ void quickjsrb_init_crypto_subtle(JSContext *ctx, JSValueConst j_crypto)
974
975
  {
975
976
  JSValue j_subtle = JS_NewObject(ctx);
976
977
  JS_SetPropertyStr(ctx, j_subtle, "digest",
977
- JS_NewCFunction(ctx, js_subtle_digest, "digest", 2));
978
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_digest, "digest", 2));
978
979
  JS_SetPropertyStr(ctx, j_subtle, "generateKey",
979
- JS_NewCFunction(ctx, js_subtle_generate_key, "generateKey", 3));
980
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_generate_key, "generateKey", 3));
980
981
  JS_SetPropertyStr(ctx, j_subtle, "importKey",
981
- JS_NewCFunction(ctx, js_subtle_import_key, "importKey", 5));
982
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_import_key, "importKey", 5));
982
983
  JS_SetPropertyStr(ctx, j_subtle, "exportKey",
983
- JS_NewCFunction(ctx, js_subtle_export_key, "exportKey", 2));
984
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_export_key, "exportKey", 2));
984
985
  JS_SetPropertyStr(ctx, j_subtle, "encrypt",
985
- JS_NewCFunction(ctx, js_subtle_encrypt, "encrypt", 3));
986
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_encrypt, "encrypt", 3));
986
987
  JS_SetPropertyStr(ctx, j_subtle, "decrypt",
987
- JS_NewCFunction(ctx, js_subtle_decrypt, "decrypt", 3));
988
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_decrypt, "decrypt", 3));
988
989
  JS_SetPropertyStr(ctx, j_subtle, "sign",
989
- JS_NewCFunction(ctx, js_subtle_sign, "sign", 3));
990
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_sign, "sign", 3));
990
991
  JS_SetPropertyStr(ctx, j_subtle, "verify",
991
- JS_NewCFunction(ctx, js_subtle_verify, "verify", 4));
992
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_verify, "verify", 4));
992
993
  JS_SetPropertyStr(ctx, j_subtle, "deriveBits",
993
- JS_NewCFunction(ctx, js_subtle_derive_bits, "deriveBits", 3));
994
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_derive_bits, "deriveBits", 3));
994
995
  JS_SetPropertyStr(ctx, j_subtle, "deriveKey",
995
- JS_NewCFunction(ctx, js_subtle_derive_key, "deriveKey", 5));
996
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_derive_key, "deriveKey", 5));
996
997
  JS_SetPropertyStr(ctx, j_subtle, "wrapKey",
997
- JS_NewCFunction(ctx, js_subtle_wrap_key, "wrapKey", 4));
998
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_wrap_key, "wrapKey", 4));
998
999
  JS_SetPropertyStr(ctx, j_subtle, "unwrapKey",
999
- JS_NewCFunction(ctx, js_subtle_unwrap_key, "unwrapKey", 7));
1000
+ quickjsrb_new_ruby_bridge(ctx, js_subtle_unwrap_key, "unwrapKey", 7));
1000
1001
  JS_SetPropertyStr(ctx, j_crypto, "subtle", j_subtle);
1001
1002
  }
@@ -203,13 +203,13 @@ void quickjsrb_init_file_proxy(VMData *data)
203
203
  JSValue j_factory_fn = JS_Eval(data->context, factory_src, strlen(factory_src), "<file-proxy>", JS_EVAL_TYPE_GLOBAL);
204
204
 
205
205
  JSValue j_helpers[7];
206
- j_helpers[0] = JS_NewCFunction(data->context, js_ruby_file_name, "__rb_file_name", 1);
207
- j_helpers[1] = JS_NewCFunction(data->context, js_ruby_file_size, "__rb_file_size", 1);
208
- j_helpers[2] = JS_NewCFunction(data->context, js_ruby_file_type, "__rb_file_type", 1);
209
- j_helpers[3] = JS_NewCFunction(data->context, js_ruby_file_last_modified, "__rb_file_last_modified", 1);
210
- j_helpers[4] = JS_NewCFunction(data->context, js_ruby_file_text, "__rb_file_text", 1);
211
- j_helpers[5] = JS_NewCFunction(data->context, js_ruby_file_array_buffer, "__rb_file_array_buffer", 1);
212
- j_helpers[6] = JS_NewCFunction(data->context, js_ruby_file_slice, "__rb_file_slice", 4);
206
+ j_helpers[0] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_name, "__rb_file_name", 1);
207
+ j_helpers[1] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_size, "__rb_file_size", 1);
208
+ j_helpers[2] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_type, "__rb_file_type", 1);
209
+ j_helpers[3] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_last_modified, "__rb_file_last_modified", 1);
210
+ j_helpers[4] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_text, "__rb_file_text", 1);
211
+ j_helpers[5] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_array_buffer, "__rb_file_array_buffer", 1);
212
+ j_helpers[6] = quickjsrb_new_ruby_bridge(data->context, js_ruby_file_slice, "__rb_file_slice", 4);
213
213
 
214
214
  data->j_file_proxy_creator = JS_Call(data->context, j_factory_fn, JS_UNDEFINED, 7, j_helpers);
215
215
 
@@ -8,13 +8,21 @@ module Quickjs
8
8
  # `source:` accepts either a `String` (eager) or a `Proc` returning one
9
9
  # (lazy). The lazy form lets a companion gem call `register_polyfill`
10
10
  # at require time without paying the file-read cost unless a VM
11
- # actually opts into the feature.
11
+ # actually opts into the feature. The Proc must not itself construct a
12
+ # VM with the same feature: compilation is locked per entry, so that
13
+ # recursion raises ThreadError instead of deadlocking silently.
14
+ #
15
+ # Re-registering a name replaces the entry wholesale, lock included: a
16
+ # VM construction already compiling under the old entry finishes
17
+ # against it (and loads the old bytecode) while the first construction
18
+ # under the new entry compiles the new source independently. Each
19
+ # registration compiles at most once; the two compiles can overlap.
12
20
  def self.register_polyfill(name, source:, init: nil)
13
21
  raise ::TypeError, "name must be a Symbol, got #{name.class}" unless name.is_a?(Symbol)
14
22
  raise ::TypeError, "source: must be a String or Proc, got #{source.class}" unless source.is_a?(String) || source.is_a?(Proc)
15
23
  raise ::TypeError, "init: must be a String or nil, got #{init.class}" unless init.nil? || init.is_a?(String)
16
24
 
17
- @_polyfills[name] = {source: source, init: init&.freeze, bytecode: nil}
25
+ @_polyfills[name] = {source: source, init: init&.freeze, bytecode: nil, mutex: Mutex.new}
18
26
  nil
19
27
  end
20
28
 
@@ -30,15 +38,26 @@ module Quickjs
30
38
  def self._apply_registered_polyfills(vm, features)
31
39
  features.each do |feature|
32
40
  next unless (entry = @_polyfills[feature])
33
- # `||=` isn't atomic `_precompile_polyfill` releases the GVL inside
34
- # `Quickjs.compile`, so two threads racing to construct VMs with the
35
- # same polyfill can both see nil and both compile. The bytecode write
36
- # is harmless because both threads produce identical bytes; the only
37
- # observable cost is wasted compile work, and (for `source: Proc`) a
38
- # second Proc invocation so register Procs that are safe to call
39
- # more than once.
40
- entry[:bytecode] ||= _precompile_polyfill(entry, feature)
41
- vm.send(:_load_polyfill_bytecode, entry[:bytecode])
41
+ # The per-entry mutex makes first-use compilation happen exactly once
42
+ # even when threads race to construct VMs with the same polyfill —
43
+ # `||=` alone isn't atomic (`_precompile_polyfill` yields the GVL
44
+ # inside `Quickjs.compile`), and a losing thread could otherwise
45
+ # double-compile or read entry[:source] after the winner cleared it.
46
+ # A compile failure leaves bytecode nil and source intact, so a later
47
+ # attempt can retry. The unlocked first read keeps the post-compile
48
+ # hot path (warmer pools constructing VMs concurrently) off the lock;
49
+ # a stale nil just falls into the synchronize.
50
+ bytecode = entry[:bytecode] || entry[:mutex].synchronize {
51
+ entry[:bytecode] ||= begin
52
+ compiled = _precompile_polyfill(entry, feature)
53
+ # The Proc / String source isn't needed again once the bytecode
54
+ # is cached — drop it so its captured scope can be GC'd.
55
+ entry[:source] = nil
56
+ entry[:init] = nil
57
+ compiled
58
+ end
59
+ }
60
+ vm.send(:_load_polyfill_bytecode, bytecode)
42
61
  end
43
62
  end
44
63
 
@@ -351,6 +351,8 @@ module Quickjs
351
351
  cipher = OpenSSL::Cipher.new("aes-#{key.algorithm["length"]}-gcm")
352
352
  cipher.encrypt
353
353
  cipher.key = key.key_data
354
+ # WebCrypto allows any nonzero IV length for AES-GCM; OpenSSL defaults to 12 bytes
355
+ cipher.iv_len = iv.bytesize
354
356
  cipher.iv = iv
355
357
  cipher.auth_data = additional_data || ""
356
358
  ciphertext = cipher.update(data) + cipher.final
@@ -365,12 +367,16 @@ module Quickjs
365
367
  additional_data = params[:additional_data]
366
368
 
367
369
  tag_bytes = tag_length / 8
370
+ if data.bytesize < tag_bytes
371
+ raise ArgumentError, "SubtleCrypto: AES-GCM data is shorter than the #{tag_bytes}-byte authentication tag"
372
+ end
368
373
  ciphertext = data[0, data.bytesize - tag_bytes]
369
374
  tag = data[-tag_bytes, tag_bytes]
370
375
 
371
376
  decipher = OpenSSL::Cipher.new("aes-#{key.algorithm["length"]}-gcm")
372
377
  decipher.decrypt
373
378
  decipher.key = key.key_data
379
+ decipher.iv_len = iv.bytesize
374
380
  decipher.iv = iv
375
381
  decipher.auth_tag = tag
376
382
  decipher.auth_data = additional_data || ""
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quickjs
4
- VERSION = "0.20.0"
4
+ VERSION = "0.21.0.rc1"
5
5
  end
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "quickjs-rb-polyfills",
3
- "version": "0.20.0",
3
+ "version": "0.21.0.rc1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "quickjs-rb-polyfills",
9
- "version": "0.20.0",
9
+ "version": "0.21.0.rc1",
10
10
  "devDependencies": {
11
11
  "rolldown": "^1.0.0-rc.14"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quickjs-rb-polyfills",
3
- "version": "0.20.0",
3
+ "version": "0.21.0.rc1",
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "build": "rolldown -c rolldown.config.mjs"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quickjs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.21.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - hmsk