mini_racer_rs 1.1.0 → 1.1.1

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: 8ed46336604ed832bdf22770d4c57d3ce98757187a901b1a762d8732bb4a8007
4
- data.tar.gz: 8e7bd77bf38d9ac5e59663e26f7e56fb9dc8642ad5cd1bd2d85e014d015f5b3a
3
+ metadata.gz: 1cd380b3e8e2ba2c09a4e389bac31dfe9b6c1557660df3d9cadfd32f48e42648
4
+ data.tar.gz: 1bd9456a98bfce434951619a3e5eaf47dc39fd9b7825f8476e02656f8e5d4a5a
5
5
  SHA512:
6
- metadata.gz: ad1576874e3a13acdcf373ebb7dfaa22254f1a3320c7774241c9ad830c033c12ad514c86164a24ff0a63ca495bc6f1bb57104fafd58b923929d76c8fae0a1725
7
- data.tar.gz: c3b8ebccb0dc686242ee8b257712b11c81d4986e92b25470f33e8fb8505e34dd9fbc60e98869dde424b060e147474cefa7a210bc6a8496ffd66b9590a5a05c18
6
+ metadata.gz: 7975cc80b2c3d1be558d518da06144c5f06783ff71fe82fd032964bf97012e844e113c26a4237fb0d3810e91089eab544c91ccc64f4f1400ec30a4d205644877
7
+ data.tar.gz: 36361b28bc1d112ed6074c9a5026e4b1e70926ba8eec19e533f9b052d8ae9f4b8fce1f235df2c8ae04ca220a91a65dbacf9f1351f00714739dcec5286140624a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.1](https://github.com/andymai/mini_racer_rs/compare/v1.1.0...v1.1.1) (2026-10-21)
4
+
5
+
6
+ ### Performance
7
+
8
+ * **ext:** park at once for contexts whose answers outlast the spin budget ([#105](https://github.com/andymai/mini_racer_rs/issues/105)) ([66af89b](https://github.com/andymai/mini_racer_rs/commit/66af89b8d22ebe0c4dd28c877155131e3d7e5738))
9
+ * **wire:** add criterion benches for decoding, writing and transcoding ([#106](https://github.com/andymai/mini_racer_rs/issues/106)) ([42fc2b5](https://github.com/andymai/mini_racer_rs/commit/42fc2b50dba2a4a6437456293fbe61aa5d29aef8))
10
+
3
11
  ## [1.1.0](https://github.com/andymai/mini_racer_rs/compare/v1.0.6...v1.1.0) (2026-10-19)
4
12
 
5
13
 
data/Cargo.lock CHANGED
@@ -17,6 +17,27 @@ dependencies = [
17
17
  "memchr",
18
18
  ]
19
19
 
20
+ [[package]]
21
+ name = "alloca"
22
+ version = "0.4.0"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
25
+ dependencies = [
26
+ "cc",
27
+ ]
28
+
29
+ [[package]]
30
+ name = "anes"
31
+ version = "0.1.6"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
34
+
35
+ [[package]]
36
+ name = "anstyle"
37
+ version = "1.0.14"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
40
+
20
41
  [[package]]
21
42
  name = "autocfg"
22
43
  version = "1.5.1"
@@ -59,6 +80,12 @@ dependencies = [
59
80
  "displaydoc",
60
81
  ]
61
82
 
83
+ [[package]]
84
+ name = "cast"
85
+ version = "0.3.0"
86
+ source = "registry+https://github.com/rust-lang/crates.io-index"
87
+ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
88
+
62
89
  [[package]]
63
90
  name = "cc"
64
91
  version = "1.8.0"
@@ -95,6 +122,33 @@ dependencies = [
95
122
  "rand_core",
96
123
  ]
97
124
 
125
+ [[package]]
126
+ name = "ciborium"
127
+ version = "0.2.2"
128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
129
+ checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
130
+ dependencies = [
131
+ "ciborium-io",
132
+ "ciborium-ll",
133
+ "serde",
134
+ ]
135
+
136
+ [[package]]
137
+ name = "ciborium-io"
138
+ version = "0.2.2"
139
+ source = "registry+https://github.com/rust-lang/crates.io-index"
140
+ checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
141
+
142
+ [[package]]
143
+ name = "ciborium-ll"
144
+ version = "0.2.2"
145
+ source = "registry+https://github.com/rust-lang/crates.io-index"
146
+ checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
147
+ dependencies = [
148
+ "ciborium-io",
149
+ "half",
150
+ ]
151
+
98
152
  [[package]]
99
153
  name = "clang-sys"
100
154
  version = "1.9.1"
@@ -106,6 +160,31 @@ dependencies = [
106
160
  "libloading",
107
161
  ]
108
162
 
163
+ [[package]]
164
+ name = "clap"
165
+ version = "4.6.7"
166
+ source = "registry+https://github.com/rust-lang/crates.io-index"
167
+ checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
168
+ dependencies = [
169
+ "clap_builder",
170
+ ]
171
+
172
+ [[package]]
173
+ name = "clap_builder"
174
+ version = "4.6.7"
175
+ source = "registry+https://github.com/rust-lang/crates.io-index"
176
+ checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
177
+ dependencies = [
178
+ "anstyle",
179
+ "clap_lex",
180
+ ]
181
+
182
+ [[package]]
183
+ name = "clap_lex"
184
+ version = "1.1.1"
185
+ source = "registry+https://github.com/rust-lang/crates.io-index"
186
+ checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
187
+
109
188
  [[package]]
110
189
  name = "core_detect"
111
190
  version = "1.0.0"
@@ -139,6 +218,45 @@ dependencies = [
139
218
  "cfg-if",
140
219
  ]
141
220
 
221
+ [[package]]
222
+ name = "criterion"
223
+ version = "0.8.2"
224
+ source = "registry+https://github.com/rust-lang/crates.io-index"
225
+ checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
226
+ dependencies = [
227
+ "alloca",
228
+ "anes",
229
+ "cast",
230
+ "ciborium",
231
+ "clap",
232
+ "criterion-plot",
233
+ "itertools",
234
+ "num-traits",
235
+ "oorandom",
236
+ "page_size",
237
+ "regex",
238
+ "serde",
239
+ "serde_json",
240
+ "tinytemplate",
241
+ "walkdir",
242
+ ]
243
+
244
+ [[package]]
245
+ name = "criterion-plot"
246
+ version = "0.8.2"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
249
+ dependencies = [
250
+ "cast",
251
+ "itertools",
252
+ ]
253
+
254
+ [[package]]
255
+ name = "crunchy"
256
+ version = "0.2.4"
257
+ source = "registry+https://github.com/rust-lang/crates.io-index"
258
+ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
259
+
142
260
  [[package]]
143
261
  name = "deno_core_icudata"
144
262
  version = "0.78.0"
@@ -262,6 +380,17 @@ dependencies = [
262
380
  "crc32fast",
263
381
  ]
264
382
 
383
+ [[package]]
384
+ name = "half"
385
+ version = "2.7.1"
386
+ source = "registry+https://github.com/rust-lang/crates.io-index"
387
+ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
388
+ dependencies = [
389
+ "cfg-if",
390
+ "crunchy",
391
+ "zerocopy",
392
+ ]
393
+
265
394
  [[package]]
266
395
  name = "home"
267
396
  version = "0.5.12"
@@ -464,7 +593,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
464
593
 
465
594
  [[package]]
466
595
  name = "mini_racer_engine"
467
- version = "1.1.0"
596
+ version = "1.1.1"
468
597
  dependencies = [
469
598
  "deno_core_icudata",
470
599
  "libc",
@@ -477,7 +606,7 @@ dependencies = [
477
606
 
478
607
  [[package]]
479
608
  name = "mini_racer_rs"
480
- version = "1.1.0"
609
+ version = "1.1.1"
481
610
  dependencies = [
482
611
  "magnus",
483
612
  "mini_racer_engine",
@@ -488,7 +617,7 @@ dependencies = [
488
617
 
489
618
  [[package]]
490
619
  name = "mini_racer_sourcemap"
491
- version = "1.1.0"
620
+ version = "1.1.1"
492
621
  dependencies = [
493
622
  "memchr",
494
623
  "serde",
@@ -498,7 +627,7 @@ dependencies = [
498
627
 
499
628
  [[package]]
500
629
  name = "mini_racer_sync"
501
- version = "1.1.0"
630
+ version = "1.1.1"
502
631
  dependencies = [
503
632
  "loom",
504
633
  "thiserror",
@@ -506,8 +635,9 @@ dependencies = [
506
635
 
507
636
  [[package]]
508
637
  name = "mini_racer_wire"
509
- version = "1.1.0"
638
+ version = "1.1.1"
510
639
  dependencies = [
640
+ "criterion",
511
641
  "proptest",
512
642
  "thiserror",
513
643
  ]
@@ -561,6 +691,22 @@ version = "1.21.4"
561
691
  source = "registry+https://github.com/rust-lang/crates.io-index"
562
692
  checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
563
693
 
694
+ [[package]]
695
+ name = "oorandom"
696
+ version = "11.1.5"
697
+ source = "registry+https://github.com/rust-lang/crates.io-index"
698
+ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
699
+
700
+ [[package]]
701
+ name = "page_size"
702
+ version = "0.6.0"
703
+ source = "registry+https://github.com/rust-lang/crates.io-index"
704
+ checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
705
+ dependencies = [
706
+ "libc",
707
+ "winapi",
708
+ ]
709
+
564
710
  [[package]]
565
711
  name = "paste"
566
712
  version = "1.0.15"
@@ -753,6 +899,15 @@ version = "1.0.23"
753
899
  source = "registry+https://github.com/rust-lang/crates.io-index"
754
900
  checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
755
901
 
902
+ [[package]]
903
+ name = "same-file"
904
+ version = "1.0.6"
905
+ source = "registry+https://github.com/rust-lang/crates.io-index"
906
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
907
+ dependencies = [
908
+ "winapi-util",
909
+ ]
910
+
756
911
  [[package]]
757
912
  name = "scoped-tls"
758
913
  version = "1.0.1"
@@ -975,6 +1130,16 @@ dependencies = [
975
1130
  "zerovec",
976
1131
  ]
977
1132
 
1133
+ [[package]]
1134
+ name = "tinytemplate"
1135
+ version = "1.2.1"
1136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1137
+ checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
1138
+ dependencies = [
1139
+ "serde",
1140
+ "serde_json",
1141
+ ]
1142
+
978
1143
  [[package]]
979
1144
  name = "tracing"
980
1145
  version = "0.1.44"
@@ -1059,6 +1224,16 @@ version = "0.1.1"
1059
1224
  source = "registry+https://github.com/rust-lang/crates.io-index"
1060
1225
  checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1061
1226
 
1227
+ [[package]]
1228
+ name = "walkdir"
1229
+ version = "2.5.0"
1230
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1231
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1232
+ dependencies = [
1233
+ "same-file",
1234
+ "winapi-util",
1235
+ ]
1236
+
1062
1237
  [[package]]
1063
1238
  name = "which"
1064
1239
  version = "6.0.3"
@@ -1087,6 +1262,15 @@ version = "0.4.0"
1087
1262
  source = "registry+https://github.com/rust-lang/crates.io-index"
1088
1263
  checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1089
1264
 
1265
+ [[package]]
1266
+ name = "winapi-util"
1267
+ version = "0.1.11"
1268
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1269
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1270
+ dependencies = [
1271
+ "windows-sys 0.61.2",
1272
+ ]
1273
+
1090
1274
  [[package]]
1091
1275
  name = "winapi-x86_64-pc-windows-gnu"
1092
1276
  version = "0.4.0"
@@ -1225,6 +1409,26 @@ dependencies = [
1225
1409
  "synstructure",
1226
1410
  ]
1227
1411
 
1412
+ [[package]]
1413
+ name = "zerocopy"
1414
+ version = "0.8.63"
1415
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1416
+ checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
1417
+ dependencies = [
1418
+ "zerocopy-derive",
1419
+ ]
1420
+
1421
+ [[package]]
1422
+ name = "zerocopy-derive"
1423
+ version = "0.8.63"
1424
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1425
+ checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
1426
+ dependencies = [
1427
+ "proc-macro2",
1428
+ "quote",
1429
+ "syn 2.0.119",
1430
+ ]
1431
+
1228
1432
  [[package]]
1229
1433
  name = "zerofrom"
1230
1434
  version = "0.1.8"
data/Cargo.toml CHANGED
@@ -15,9 +15,10 @@ license = "MIT"
15
15
  publish = false
16
16
  repository = "https://github.com/andymai/mini_racer_rs"
17
17
  rust-version = "1.91"
18
- version = "1.1.0"
18
+ version = "1.1.1"
19
19
 
20
20
  [workspace.dependencies]
21
+ criterion = {version = "0.8", default-features = false, features = ["cargo_bench_support"]}
21
22
  deno_core_icudata = "=0.78.0"
22
23
  libc = "0.2"
23
24
  loom = "0.7.2"
data/README.md CHANGED
@@ -15,42 +15,42 @@ A drop-in replacement for mini_racer, implemented in Rust on V8 15.2.
15
15
 
16
16
  </div>
17
17
 
18
- mini_racer_rs implements the `MiniRacer` Ruby API from mini_racer 0.22.1, with faster Ruby and JavaScript round trips, V8 15.2, prebuilt native gems, fiber scheduler support, and safer handling of difficult values. It defines the `MiniRacer` namespace and ships `lib/mini_racer.rb`, so `require "mini_racer"` and ExecJS continue to work.
18
+ mini_racer_rs implements the `MiniRacer` API from mini_racer 0.22.1. It defines `MiniRacer` and ships `lib/mini_racer.rb`, so `require "mini_racer"` and ExecJS keep working.
19
19
 
20
20
  > mini_racer_rs is an independent project and is not affiliated with the Discourse staff who maintain mini_racer.
21
21
 
22
22
  ## Highlights
23
23
 
24
- - **JavaScript round trips are 5 to 9 times faster.** An eval takes 1.09 µs against 6.91 µs, a call takes 0.72 µs against 6.51 µs, and a callback takes 1.12 µs against 6.12 µs. A call on a context with `timeout:` takes 0.76 µs instead of 20.48 µs because one watchdog serves the process.
25
- - **A Babel transform through ExecJS is 1.95x faster.** It takes 5.15 ms instead of 10.05 ms. Babel itself takes 5.3 ms inside V8 15.2, against 10.3 ms inside V8 13.6, while Terser and React rendering take about the same time in both gems.
26
- - **A 44 MB production server rendering bundle loads 4.6x faster.** It loads into a new context in 215 ms against 983 ms, measured at commit `2513a48`, and scripts of 1 MB or more compile from a process-wide V8 code cache. At commit `220bfe7`, the 8.2 MB MUI bundle loads and renders once in 105 ms against 167 ms, while startup plus Babel takes 13.3 ms against 44.4 ms.
27
- - **A context holding the MUI bundle uses 44 MB instead of 110 MB.** At commit `220bfe7`, a context holding Babel uses 15.2 MB against 28.5 MB, and contexts loading the same large script read one shared copy of its source. A context holding the 44 MB production bundle used 136 MB against 301 MB at commit `2513a48`, before sources were shared. When 20,000 contexts are left to Ruby's garbage collector, resident memory peaks at 102 MB against 2,920 MB.
28
- - **Forks pause worker work, and children use their own threads.** For forks made through Ruby's `fork`, `IO.popen("-")`, or `Process.daemon`, mini_racer_rs waits for context runners, pauses its worker pool, and waits for running tasks; an async `WebAssembly.compile` in a new default-mode child completes on the child's own pool. On Ubuntu 24.04 with glibc 2.39, 20 of 20 mini_racer children aborted with signal 6 after loading and rendering React, while 20 of 20 mini_racer_rs children exited cleanly. On Fedora, mini_racer children hung within the first 50 forks of `test/stress/fork_busy.rb`, while mini_racer_rs completed 300 forks per run on Fedora and Ubuntu.
29
- - **V8 15.2.124.1.** mini_racer 0.22.1 uses V8 13.6.233.17.
30
- - **No compiler at release install.** Native gems cover glibc Linux on x86_64 and aarch64, plus macOS on arm64 and x86_64. Each carries extensions for Ruby 3.3, 3.4, and 4.0. mini_racer 0.22.1 ships as source and compiles C++ against libv8-node.
31
- - **Fiber scheduler support.** A fiber waiting on JavaScript lets other fibers on its thread run. Three 100 ms busy loops in separate contexts and fibers finish in 100 ms, against 300 ms when mini_racer blocks the thread.
32
- - **171 of 174 upstream tests pass.** The mini_racer suite runs unmodified, with its other 3 tests skipped by mini_racer itself. ExecJS passes 197 of 198 tests on its MiniRacer runtime, with 1 skipped.
33
- - **Difficult values return safely.** A `Map` with a function key returns an error Hash instead of aborting the process with SIGABRT. `Object(1n)` returns `1`, and an offset typed array returns its buffer as a binary String.
34
- - **Four checked layers.** A script enforces crate boundaries, `unsafe` is confined to four fenced modules, loom model-checks the thread handoff, fuzzing covers the wire codec, sanitizers run nightly, and results are differentially tested against mini_racer. The release pipeline runs the full suites for every released extension.
24
+ - **Round trips:** 5 to 9 times faster (0.72 µs vs 6.51 µs per call).
25
+ - **Babel through ExecJS:** 1.95x faster (5.15 ms vs 10.05 ms).
26
+ - **Bundle load:** 4.6x faster for a 44 MB production server rendering bundle (215 ms vs 983 ms).
27
+ - **Memory:** A context holding the MUI bundle uses 44 MB vs 110 MB.
28
+ - **Forks:** After loading and rendering React: 20 of 20 clean mini_racer_rs exits vs 20 of 20 mini_racer aborts.
29
+ - **V8:** V8 15.2.124.1 vs V8 13.6.233.17 in mini_racer 0.22.1.
30
+ - **No compiler:** Native gems cover glibc Linux and macOS, both architectures, with Ruby 3.3, 3.4 and 4.0 extensions.
31
+ - **Fiber scheduler:** Three 100 ms loops in separate contexts and fibers finish in 100 ms vs 300 ms.
32
+ - **Upstream suites:** mini_racer passes 171 of 174 with 3 skipped by itself; ExecJS passes 197 of 198 with 1 skipped.
33
+ - **Difficult values:** A `Map` with a function key returns an error Hash instead of aborting with SIGABRT.
34
+ - **Checks:** Script-enforced crate boundaries, four fenced `unsafe` modules, loom, wire-codec fuzzing, nightly sanitizers and mini_racer differential tests.
35
35
 
36
- All performance numbers compare mini_racer_rs with mini_racer 0.22.1 on Ruby 3.4.2 and Linux on an AMD Ryzen 9 7950X3D, with both gems pinned to the same eight cores and the `performance` governor.
36
+ These numbers compare with mini_racer 0.22.1. See [Benchmarks](#benchmarks) for the method.
37
37
 
38
38
  ## Install
39
39
 
40
- The package switch is one Gemfile line:
40
+ Switch packages with one Gemfile line:
41
41
 
42
42
  ```diff
43
43
  - gem "mini_racer"
44
44
  + gem "mini_racer_rs"
45
45
  ```
46
46
 
47
- Run `bundle install`. Bundler is required because RubyGems may otherwise activate the original `mini_racer` first. Loading both gems raises a `LoadError` that names the conflict. Reverse the Gemfile change to switch back.
47
+ Run `bundle install`. Bundler is required because RubyGems may activate the original `mini_racer` first. Loading both gems raises a `LoadError` naming the conflict. Reverse the Gemfile change to switch back.
48
48
 
49
- On x86_64-linux-gnu, aarch64-linux-gnu, arm64-darwin, and x86_64-darwin, `bundle install` installs a native gem and compiles nothing. Elsewhere, the source gem builds the extension and requires Rust 1.91 or newer and libclang. To build from a checkout, follow [CONTRIBUTING.md](CONTRIBUTING.md) and run `bin/setup`.
49
+ On x86_64-linux-gnu, aarch64-linux-gnu, arm64-darwin, and x86_64-darwin, `bundle install` installs a native gem without compiling. Elsewhere, the source gem requires Rust 1.91 or newer and libclang to build the extension. For a checkout, follow [CONTRIBUTING.md](CONTRIBUTING.md) and run `bin/setup`.
50
50
 
51
51
  ## Quick start
52
52
 
53
- This example was run against both gems and produced exactly the outputs shown:
53
+ Both gems produced exactly these outputs:
54
54
 
55
55
  ```ruby check
56
56
  require "mini_racer"
@@ -112,31 +112,35 @@ ExecJS.eval("[1, 2].map(x => x * 2)") # => [2, 4]
112
112
  | `ensure_gc_after_idle:` | Idle milliseconds before one full garbage collection. `0` disables it. |
113
113
  | `snapshot:` | A `MiniRacer::Snapshot` used to initialize the context. |
114
114
 
115
- Errors are `MiniRacer::ParseError`, `MiniRacer::RuntimeError` with JavaScript frames in its backtrace, `MiniRacer::ScriptTerminatedError`, `MiniRacer::V8OutOfMemoryError`, `MiniRacer::ContextDisposedError`, `MiniRacer::SnapshotError`, and `MiniRacer::PlatformAlreadyInitialized`. All descend from `MiniRacer::Error`.
115
+ Errors are `MiniRacer::ParseError`, `MiniRacer::RuntimeError`, `MiniRacer::ScriptTerminatedError`, `MiniRacer::V8OutOfMemoryError`, `MiniRacer::ContextDisposedError`, `MiniRacer::SnapshotError`, and `MiniRacer::PlatformAlreadyInitialized`. `MiniRacer::RuntimeError` includes JavaScript frames in its backtrace. All descend from `MiniRacer::Error`.
116
116
 
117
- **Source maps.** The bundle must be a UTF-8 String of at least 1,024 bytes, and the map must be a JSON String in source map v3 format. When a context evaluates a UTF-8 source containing that bundle, `MiniRacer::RuntimeError` backtraces replace V8 positions inside it with mapped original file, line, and column positions while retaining V8 function names. Async frames, frames after the tenth, `eval` or `new Function` code, frames outside the bundle, positions the map does not cover, and stacks reformatted by JavaScript keep V8 positions. Detailed stack capture makes creating and throwing errors in affected contexts more expensive, while measured React and MUI server renders were unchanged. Registration is process wide and holds at most eight bundles, and a map cannot be removed; registering a bundle again replaces its map. Trust bundles and maps like application code because source names appear in Ruby backtraces.
117
+ **Source maps.** Bundles must be UTF-8 Strings of at least 1,024 bytes. Maps must be JSON Strings in source map v3 format. When a context evaluates UTF-8 source containing a registered bundle, mini_racer_rs rewrites `MiniRacer::RuntimeError` backtraces. V8 positions inside the bundle become mapped original file, line, and column positions. V8 function names remain.
118
+
119
+ V8 positions remain for async frames and frames after the tenth. They also remain for `eval`, `new Function`, uncovered positions, and frames outside the bundle. JavaScript-reformatted stacks also keep V8 positions.
120
+
121
+ Detailed stack capture makes creating and throwing errors more expensive in affected contexts. Measured React and MUI server renders were unchanged. Registration is process wide and holds at most eight bundles. Maps cannot be removed. Registering a bundle again replaces its map. Trust bundles and maps like application code because source names appear in Ruby backtraces.
118
122
 
119
123
  ## Switching from mini_racer
120
124
 
121
125
  ### What changes
122
126
 
123
- The public API and require path stay the same. The main changes are native installation, faster small calls and Babel transforms, fiber-aware waiting, newer V8 behavior, and safer conversion of difficult values. Applications that make many small calls gain the most. See [Highlights](#highlights) for measurements.
127
+ The public API and require path stay the same. Changes include native installation, faster calls and Babel transforms, fiber-aware waiting, newer V8 behavior, and safer value conversion. `Object(1n)` returns `1`, and an offset typed array returns its buffer as a binary String. Applications making many small calls gain the most. See [Highlights](#highlights) for measurements.
124
128
 
125
129
  ### Check before you ship
126
130
 
127
- - You're affected if your tests or code compare exact JavaScript error messages or locale-formatted text. V8 15.2 can word them differently from V8 13.6.
131
+ - You're affected if tests or code compare exact JavaScript errors or locale-formatted text. V8 15.2 may differ from V8 13.6.
128
132
 
129
- - You're affected if an attached Ruby callback calls back into its own context from another fiber, such as inside `Enumerator#next`. mini_racer_rs raises `ThreadError`; mini_racer allows it.
133
+ - You're affected if an attached callback reenters its context from another fiber, including inside `Enumerator#next`. mini_racer_rs raises `ThreadError`; mini_racer allows it.
130
134
 
131
- - You're affected if a callback exits by `throw`, returns out of a proc, or is stopped by `Thread#kill`. The script ends with `Error: terminated`, then the jump continues after `eval` returns. mini_racer hangs.
135
+ - You're affected if a callback exits by `throw`, returns from a proc, or receives `Thread#kill`. The script ends with `Error: terminated`. The jump continues after `eval` returns. mini_racer hangs.
132
136
 
133
- - You're affected if JavaScript reads a non-ASCII message from a callback exception. mini_racer_rs passes UTF-8, such as `"café"`, while mini_racer passes `"café"`.
137
+ - You're affected if JavaScript reads a callback exception's non-ASCII message. mini_racer_rs passes UTF-8, such as `"café"`; mini_racer passes `"café"`.
134
138
 
135
- - You're affected if `fork`, `IO.popen("-")`, or `Process.daemon` runs after V8 starts. Both modes wait up to 500 ms for runners, interrupting JavaScript after 50 ms; both then pause the worker pool and wait up to 100 ms for running tasks. Uninterruptible operations and workers can outlast these best-effort waits, and the fork proceeds. Default children reject inherited contexts; new contexts run worker work on the child's own threads, but are best effort after either wait expires. `:single_threaded` children take over idle inherited contexts, reject busy ones (all after runner timeout), and accept new contexts.
139
+ - You're affected if `fork`, `IO.popen("-")`, or `Process.daemon` runs after V8 starts. Both modes wait up to 500 ms for runners and interrupt JavaScript after 50 ms. They pause the worker pool and wait up to 100 ms for running tasks. Uninterruptible work may outlast these best-effort waits, and the fork proceeds. Default children reject inherited contexts. New contexts use the child's threads, but are best effort after either timeout. `:single_threaded` children take over idle inherited contexts and reject busy ones. After a runner timeout, they reject all inherited contexts. They accept new contexts.
136
140
 
137
- - You're affected if, with `:single_threaded`, you fork in a way that bypasses Ruby's `fork`, such as a C-level `fork()`. Inherited contexts are unusable in the child.
141
+ - You're affected if `:single_threaded` forks bypass Ruby's `fork`, such as a C-level `fork()`. Inherited contexts are unusable in the child.
138
142
 
139
- - You're affected if another thread calls `dispose` while a call is running. It returns after the call finishes and the isolate is gone. mini_racer returns immediately and frees the isolate later.
143
+ - You're affected if another thread calls `dispose` during a call. It returns after the call finishes and the isolate is gone. mini_racer returns immediately and frees the isolate later.
140
144
 
141
145
  - You're affected if your code references `MiniRacer::Loader`. It does not exist in mini_racer_rs.
142
146
 
@@ -144,26 +148,28 @@ See [COMPATIBILITY.md](COMPATIBILITY.md) for every difference.
144
148
 
145
149
  ### Stay on mini_racer if
146
150
 
147
- - You deploy on musl-based Linux such as Alpine. mini_racer supports it through libv8-node's musl gems, while mini_racer_rs does not install there. Neither gem supports Windows.
151
+ - You deploy on musl-based Linux such as Alpine. mini_racer supports it through libv8-node's musl gems; mini_racer_rs does not. Neither supports Windows.
148
152
 
149
- - Download size matters more than compile time. The largest mini_racer_rs native gem is 65.6 MB because it includes extensions for three Ruby versions. The mini_racer download on x86_64 Linux is 47.1 MB, followed by compilation.
153
+ - Download size matters more than compile time. The largest native gem is 65.6 MB because it contains three Ruby extensions. mini_racer downloads 47.1 MB on x86_64 Linux, then compiles.
150
154
 
151
155
  ### Gems that depend on mini_racer
152
156
 
153
- If another gem brings in `mini_racer`, point Bundler at the repository's shim. It is an empty gem named `mini_racer`, version 0.22.1, that depends on the `mini_racer_rs` version from the same checkout.
157
+ If another gem adds `mini_racer`, point Bundler to the repository's shim. This empty `mini_racer` gem is version 0.22.1. It depends on the `mini_racer_rs` version from the same checkout.
154
158
 
155
159
  ```ruby
156
160
  gem "mini_racer_rs"
157
161
  gem "mini_racer", github: "andymai/mini_racer_rs", tag: "vX.Y.Z", glob: "shim/*.gemspec"
158
162
  ```
159
163
 
160
- The repository tests shim resolution with `path:` sources and rtlcss in `test/consumers/transitive/`. The `github:` form has not been run end to end. A dependency whose `mini_racer` requirement excludes version 0.22.1 cannot use the shim.
164
+ The repository tests `path:` shim resolution with rtlcss in `test/consumers/transitive/`. The `github:` form has not run end to end. A dependency excluding `mini_racer` 0.22.1 cannot use the shim.
161
165
 
162
166
  ### Forking servers such as Puma
163
167
 
164
- Prime the process-wide V8 code cache before forking by evaluating each bundle of 1 MB or more twice in the master, using exactly the source text and file name the renderer uses, then dispose those contexts. The second evaluation builds the cache, later matching evaluations compile from it, and forked children inherit it. For Babel's 2.3 MB bundle at commit `5e13c1c`, a forked child's first load took, as medians across 5 trials, 60.8 ms when the parent had not loaded it, 65.5 ms after one load, and 25.0 ms after two loads. You can prime a `react-rails` app by building its server renderer twice. Parent contexts are not used in children. Only the cache carries over.
168
+ Before forking, evaluate each bundle of 1 MB or more twice in the master, then dispose those contexts. Use the renderer's exact source text and file name. The second evaluation builds the process-wide V8 code cache. Matching evaluations compile from it, and forked children inherit it. For Babel's 2.3 MB bundle at commit `5e13c1c`, a forked child's first load took 60.8 ms without a parent load. It took 65.5 ms after one load and 25.0 ms after two. These are medians across 5 trials. Prime a `react-rails` app by building its server renderer twice. Children do not use parent contexts. Only the cache carries over.
169
+
170
+ For Ruby's `fork`, `IO.popen("-")`, or `Process.daemon`, mini_racer_rs waits for context runners, pauses its worker pool and waits for running tasks. An async `WebAssembly.compile` in a new default-mode child completes on the child's own pool. On Ubuntu 24.04 with glibc 2.39, 20 of 20 mini_racer children aborted with signal 6 after React rendering; all mini_racer_rs children exited cleanly. On Fedora, mini_racer hung within 50 `test/stress/fork_busy.rb` forks; mini_racer_rs completed 300 per run on Fedora and Ubuntu.
165
171
 
166
- With `mini_racer` 0.22.1, each worker compiles the bundle from source on its first load because there is no code cache.
172
+ With `mini_racer` 0.22.1, each worker compiles the bundle from source on its first load because it has no code cache.
167
173
 
168
174
  ## Benchmarks
169
175
 
@@ -179,13 +185,28 @@ With `mini_racer` 0.22.1, each worker compiles the bundle from source on its fir
179
185
  | MUI in a new context: load the 8.2 MB bundle and render once, through ExecJS | 167 ms | 105 ms | 1.6x |
180
186
  | Load a 44 MB production server rendering bundle into a new context, through ExecJS | 983 ms | 215 ms | 4.6x |
181
187
 
182
- Results are medians of 5 runs, with every workload producing identical output in both gems. The eight library workloads use the release build of commit `220bfe7`, and the 44 MB production server rendering bundle load uses commit `2513a48`. Measurements ran on Ruby 3.4.2 and Linux on an AMD Ryzen 9 7950X3D, with the CPU governor set to `performance` and both gems pinned to CPUs 0 through 7.
188
+ Results are medians of 5 runs. Every workload produced identical output in both gems. The eight library workloads use the release build of commit `220bfe7`. The 44 MB production bundle load uses commit `2513a48`. Benchmarks ran on Ruby 3.4.2 and Linux with an AMD Ryzen 9 7950X3D. The governor was `performance`. Both gems were pinned to CPUs 0 through 7.
189
+
190
+ The per-call and memory figures below were measured on the same machine. Startup plus Babel, Babel context and MUI context figures come from commit `220bfe7`. One watchdog thread serves the whole process, making a call with `timeout:` fast. Scripts of 1 MB or more use a process-wide V8 code cache. Contexts loading the same large script share one source copy.
191
+
192
+ | Measure | mini_racer 0.22.1 | mini_racer_rs |
193
+ |---|---:|---:|
194
+ | eval round trip | 6.91 µs | 1.09 µs |
195
+ | call round trip | 6.51 µs | 0.72 µs |
196
+ | callback round trip | 6.12 µs | 1.12 µs |
197
+ | call on a context with `timeout:` | 20.48 µs | 0.76 µs |
198
+ | Babel inside V8 | 10.3 ms | 5.3 ms |
199
+ | startup plus Babel | 44.4 ms | 13.3 ms |
200
+ | context holding Babel | 28.5 MB | 15.2 MB |
201
+ | context holding the 8.2 MB MUI bundle | 110 MB | 44 MB |
202
+ | context holding the 44 MB production bundle, at commit `2513a48` before sources were shared | 301 MB | 136 MB |
203
+ | peak resident memory with 20,000 contexts left to Ruby's garbage collector | 2,920 MB | 102 MB |
183
204
 
184
205
  ```sh
185
206
  bundle exec ruby bench/run.rb --cpus 0-7
186
207
  ```
187
208
 
188
- [BENCHMARKS.md](BENCHMARKS.md) describes the method, per-call costs, memory results, and mini_racer's own `benchmark/serde` suite.
209
+ [BENCHMARKS.md](BENCHMARKS.md) covers the method, per-call costs, memory results, and mini_racer's `benchmark/serde` suite.
189
210
 
190
211
  <!-- compat-table:start -->
191
212
  ## Compatibility
@@ -200,7 +221,7 @@ See [COMPATIBILITY.md](COMPATIBILITY.md).
200
221
 
201
222
  ## How it works
202
223
 
203
- mini_racer_rs uses the `v8` crate, rusty_v8 152.2.0, with Ruby bindings through magnus and rb-sys. Each context owns a V8 isolate and a native runner thread with a stack of at least 2 MiB, so V8 frames never use a Ruby thread's stack. A per-context lock permits one dispatch at a time.
224
+ mini_racer_rs uses the `v8` crate, rusty_v8 152.2.0, with magnus and rb-sys Ruby bindings. Each context owns a V8 isolate and native runner thread. Its stack is at least 2 MiB, keeping V8 frames off Ruby thread stacks. A per-context lock allows one dispatch at a time.
204
225
 
205
226
  ```text
206
227
  Ruby thread runner thread (one per context)
@@ -216,34 +237,36 @@ mini_racer_rs uses the `v8` crate, rusty_v8 152.2.0, with Ruby bindings through
216
237
  result
217
238
  ```
218
239
 
219
- A waiting Ruby thread first spins briefly with the GVL held, then spins without it, then parks with the GVL released. Under a fiber scheduler, only the waiting fiber parks. Values use V8's serialization format and decode directly into Ruby objects without an intermediate tree. Repeated objects retain their identity.
240
+ A waiting Ruby thread spins with the GVL held, then without it. It then parks with the GVL released. After enough consecutive answers arrive beyond the spin budget, that context skips spinning and parks at once until an answer arrives within the budget. Under a fiber scheduler, only the waiting fiber parks. Values use V8's serialization format and decode directly into Ruby objects. There is no intermediate tree. Repeated objects keep their identity.
220
241
 
221
- One watchdog thread per process enforces every context's `timeout:`. In `:single_threaded` mode, a fork quiesces the runners, the child takes over contexts that were idle, and contexts in use raise there. In every process, mini_racer_rs points V8's platform at a copied vtable whose worker-thread slots post to a pool it owns. V8's platform has one worker thread, which never receives a task. A fork pauses the pool, and the parent resumes it when the fork returns; inherited contexts raise in the child in the default multi-threaded mode.
242
+ One watchdog thread per process enforces every context's `timeout:`. In `:single_threaded` mode, forks quiesce runners. Children take over idle contexts; contexts in use raise there. mini_racer_rs gives V8 a copied platform vtable in each process. Worker-thread slots post to its own pool. V8's platform has one worker thread, which receives no tasks. Forking pauses the pool. The parent resumes it afterward. Default multi-threaded children reject inherited contexts.
222
243
 
223
- The implementation is split into four crates:
244
+ The implementation has four crates:
224
245
 
225
246
  - `mini_racer_wire`: the wire codec, with no unsafe code.
226
247
  - `mini_racer_sync`: the thread protocol, with no unsafe code and loom checking.
227
- - `mini_racer_engine`: V8 process state and runners, and the only crate that names `v8`.
228
- - `mini_racer_rs`: the Ruby extension, and the only crate that names magnus or rb-sys.
248
+ - `mini_racer_engine`: V8 process state and runners, and the only crate naming `v8`.
249
+ - `mini_racer_rs`: the Ruby extension, and the only crate naming magnus or rb-sys.
250
+
251
+ The release pipeline runs the full suites for every released extension.
229
252
 
230
- [ARCHITECTURE.md](ARCHITECTURE.md) follows a call end to end and covers the design in depth.
253
+ [ARCHITECTURE.md](ARCHITECTURE.md) follows a call end to end and explains the design.
231
254
 
232
255
  ## Scope
233
256
 
234
- mini_racer_rs deliberately does not:
257
+ mini_racer_rs does not:
235
258
 
236
- - Run on Windows or musl-based Linux such as Alpine. Supported platforms are glibc Linux on x86_64 and aarch64, plus macOS on arm64 and x86_64.
259
+ - Run on Windows or musl-based Linux such as Alpine. It supports glibc Linux on x86_64 and aarch64, and macOS on arm64 and x86_64.
237
260
 
238
- - Provide Node.js APIs. JavaScript runs in plain V8 without file system, network, or process access. JavaScript can reach only the Ruby callables you `attach`.
261
+ - Provide Node.js APIs. JavaScript runs in plain V8 without file system, network, or process access. It can reach only Ruby callables you `attach`.
239
262
 
240
263
  - Work reliably without Bundler. RubyGems may activate the original mini_racer first.
241
264
 
242
- - Satisfy a dependency that pins a `mini_racer` version other than 0.22.1. This includes minibars with its `~> 0.6.4` requirement.
265
+ - Satisfy dependencies pinning `mini_racer` versions other than 0.22.1. This includes minibars and its `~> 0.6.4` requirement.
243
266
 
244
- - Extend the `MiniRacer` API beyond mini_racer 0.22.1. Deliberate differences are recorded in [COMPATIBILITY.md](COMPATIBILITY.md).
267
+ - Extend the `MiniRacer` API beyond mini_racer 0.22.1. [COMPATIBILITY.md](COMPATIBILITY.md) records deliberate differences.
245
268
 
246
- Supported Ruby implementations are CRuby 3.3, 3.4, and 4.0, plus TruffleRuby through mini_racer's unchanged TruffleRuby backend.
269
+ Supported implementations are CRuby 3.3, 3.4, and 4.0, plus TruffleRuby through mini_racer's unchanged TruffleRuby backend.
247
270
 
248
271
  ## Documentation
249
272
 
@@ -12,7 +12,12 @@ version.workspace = true
12
12
  thiserror.workspace = true
13
13
 
14
14
  [dev-dependencies]
15
+ criterion.workspace = true
15
16
  proptest.workspace = true
16
17
 
18
+ [[bench]]
19
+ harness = false
20
+ name = "wire"
21
+
17
22
  [lints]
18
23
  workspace = true
@@ -0,0 +1,293 @@
1
+ //! Decoding, writing and transcoding V8's wire format, without V8 or Ruby, on
2
+ //! the shapes the extension sees: seeded server rendering props, and runs of
3
+ //! numbers and strings. Strings take the forms V8's serializer gives them.
4
+
5
+ use std::convert::Infallible;
6
+ use std::hint::black_box;
7
+
8
+ use criterion::{Criterion, Throughput, criterion_group, criterion_main};
9
+ use mini_racer_wire::{Begun, Container, Limits, Scalar, Visitor, Writer, decode_value, text};
10
+
11
+ const WORDS: [&str; 12] = [
12
+ "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "theta", "kappa", "lambda", "sigma",
13
+ "omega", "omicron",
14
+ ];
15
+ const INTL: [&str; 5] = ["café", "naïve", "Zürich", "東京", "😀 party"];
16
+ // The words of INTL that V8 keeps one byte a character.
17
+ const LATIN1: [&str; 3] = ["café", "naïve", "Zürich"];
18
+
19
+ /// Reads every value and keeps nothing, so the decoder's own work is what
20
+ /// is measured.
21
+ struct Discard;
22
+
23
+ impl Visitor for Discard {
24
+ type Value = ();
25
+ type Frame = ();
26
+ type Error = Infallible;
27
+
28
+ fn scalar(&mut self, scalar: Scalar<'_>) -> Result<(), Infallible> {
29
+ black_box(scalar);
30
+ Ok(())
31
+ }
32
+
33
+ fn begin(&mut self, container: Container) -> Result<Begun<(), ()>, Infallible> {
34
+ black_box(container);
35
+ Ok(Begun {
36
+ frame: (),
37
+ value: None,
38
+ })
39
+ }
40
+
41
+ fn push(&mut self, (): &mut (), (): ()) -> Result<(), Infallible> {
42
+ Ok(())
43
+ }
44
+
45
+ fn insert(&mut self, (): &mut (), (): (), (): ()) -> Result<(), Infallible> {
46
+ Ok(())
47
+ }
48
+
49
+ fn end(&mut self, (): ()) -> Result<(), Infallible> {
50
+ Ok(())
51
+ }
52
+
53
+ fn register(&mut self, id: u32, (): ()) -> Result<(), Infallible> {
54
+ black_box(id);
55
+ Ok(())
56
+ }
57
+
58
+ fn back_ref(&mut self, id: u32) -> Result<(), Infallible> {
59
+ black_box(id);
60
+ Ok(())
61
+ }
62
+ }
63
+
64
+ enum Node {
65
+ Int(i64),
66
+ Number(f64),
67
+ Bool(bool),
68
+ Latin1(Vec<u8>),
69
+ Utf16(Vec<u16>),
70
+ Object(Vec<(Vec<u8>, Self)>),
71
+ Array(Vec<Self>),
72
+ }
73
+
74
+ impl Node {
75
+ fn text(text: &str) -> Self {
76
+ let latin1: Option<Vec<u8>> = text
77
+ .chars()
78
+ .map(|c| u8::try_from(u32::from(c)).ok())
79
+ .collect();
80
+ latin1.map_or_else(|| Self::Utf16(text.encode_utf16().collect()), Self::Latin1)
81
+ }
82
+
83
+ fn object(entries: Vec<(&str, Self)>) -> Self {
84
+ Self::Object(
85
+ entries
86
+ .into_iter()
87
+ .map(|(key, value)| (key.as_bytes().to_vec(), value))
88
+ .collect(),
89
+ )
90
+ }
91
+
92
+ fn write(&self, writer: &mut Writer<'_>) {
93
+ match self {
94
+ Self::Int(n) => writer.integer(*n),
95
+ Self::Number(n) => writer.number(*n),
96
+ Self::Bool(b) => writer.bool(*b),
97
+ Self::Latin1(bytes) => writer.latin1(bytes),
98
+ Self::Utf16(units) => writer.utf16_aligned(units),
99
+ Self::Object(entries) => {
100
+ writer.begin_object();
101
+ for (key, value) in entries {
102
+ writer.latin1(key);
103
+ value.write(writer);
104
+ }
105
+ writer.end_object(count(entries.len()));
106
+ }
107
+ Self::Array(items) => {
108
+ writer.begin_array(count(items.len()));
109
+ for item in items {
110
+ item.write(writer);
111
+ }
112
+ writer.end_array(count(items.len()));
113
+ }
114
+ }
115
+ }
116
+
117
+ fn bytes(&self) -> Vec<u8> {
118
+ let mut out = Vec::new();
119
+ self.write(&mut Writer::versioned(&mut out, 15));
120
+ out
121
+ }
122
+ }
123
+
124
+ fn count(len: usize) -> u32 {
125
+ u32::try_from(len).unwrap_or(u32::MAX)
126
+ }
127
+
128
+ /// xorshift64, so the payload is the same on every machine.
129
+ struct Seeded(u64);
130
+
131
+ impl Seeded {
132
+ const fn next(&mut self) -> u64 {
133
+ self.0 ^= self.0 << 13;
134
+ self.0 ^= self.0 >> 7;
135
+ self.0 ^= self.0 << 17;
136
+ self.0
137
+ }
138
+
139
+ fn below(&mut self, bound: usize) -> usize {
140
+ usize::try_from(self.next()).unwrap_or(0) % bound
141
+ }
142
+
143
+ fn words(&mut self, count: usize) -> Node {
144
+ self.words_from(count, &INTL)
145
+ }
146
+
147
+ fn words_from(&mut self, count: usize, intl: &[&str]) -> Node {
148
+ let words: Vec<&str> = (0..count)
149
+ .map(|_| {
150
+ if self.below(10) == 0 {
151
+ intl[self.below(intl.len())]
152
+ } else {
153
+ WORDS[self.below(WORDS.len())]
154
+ }
155
+ })
156
+ .collect();
157
+ Node::text(&words.join(" "))
158
+ }
159
+
160
+ fn cents(&mut self) -> f64 {
161
+ f64::from(u32::try_from(self.below(100_000)).unwrap_or(0)) / 100.0
162
+ }
163
+ }
164
+
165
+ /// About 1 MB of props, the size the end-to-end benchmark sends.
166
+ fn props() -> Node {
167
+ let mut seeded = Seeded(42);
168
+ let items = (0..5_000)
169
+ .map(|id| {
170
+ let length = 3 + seeded.below(6);
171
+ Node::object(vec![
172
+ ("id", Node::Int(id)),
173
+ (
174
+ "sku",
175
+ Node::text(&format!("SKU-{}", seeded.below(1_000_000))),
176
+ ),
177
+ ("title", seeded.words(length)),
178
+ ("price", Node::Number(seeded.cents())),
179
+ ("rating", Node::Number(seeded.cents() / 200.0)),
180
+ ("inStock", Node::Bool(seeded.below(4) != 0)),
181
+ (
182
+ "tags",
183
+ Node::Array(vec![seeded.words(1), seeded.words(1), seeded.words(1)]),
184
+ ),
185
+ (
186
+ "seller",
187
+ Node::object(vec![
188
+ ("name", seeded.words(2)),
189
+ (
190
+ "reviews",
191
+ Node::Int(i64::try_from(seeded.below(5_000)).unwrap_or(0)),
192
+ ),
193
+ ]),
194
+ ),
195
+ ])
196
+ })
197
+ .collect();
198
+ Node::object(vec![
199
+ (
200
+ "page",
201
+ Node::object(vec![
202
+ ("title", Node::text("Products")),
203
+ ("locale", Node::text("en-US")),
204
+ ]),
205
+ ),
206
+ ("items", Node::Array(items)),
207
+ ])
208
+ }
209
+
210
+ fn numbers() -> Node {
211
+ let mut seeded = Seeded(7);
212
+ Node::Array((0..10_000).map(|_| Node::Number(seeded.cents())).collect())
213
+ }
214
+
215
+ fn latin1_strings() -> Node {
216
+ let mut seeded = Seeded(9);
217
+ Node::Array((0..10_000).map(|_| seeded.words_from(2, &LATIN1)).collect())
218
+ }
219
+
220
+ fn two_byte_strings() -> Node {
221
+ Node::Array(
222
+ (0..1_000)
223
+ .map(|n| Node::text(&format!("東京の天気は晴れです {n}")))
224
+ .collect(),
225
+ )
226
+ }
227
+
228
+ fn decode(c: &mut Criterion) {
229
+ let mut group = c.benchmark_group("decode");
230
+ for (name, node) in [
231
+ ("ssr_props", props()),
232
+ ("dense_numbers", numbers()),
233
+ ("latin1_strings", latin1_strings()),
234
+ ("two_byte_strings", two_byte_strings()),
235
+ ] {
236
+ let bytes = node.bytes();
237
+ group.throughput(Throughput::Bytes(
238
+ bytes.len().try_into().unwrap_or(u64::MAX),
239
+ ));
240
+ group.bench_function(name, |b| {
241
+ b.iter(|| decode_value(black_box(&bytes), Limits::default(), &mut Discard));
242
+ });
243
+ }
244
+ group.finish();
245
+ }
246
+
247
+ fn write(c: &mut Criterion) {
248
+ let node = props();
249
+ let mut out = Vec::new();
250
+ node.write(&mut Writer::new(&mut out));
251
+ let mut group = c.benchmark_group("write");
252
+ group.throughput(Throughput::Bytes(out.len().try_into().unwrap_or(u64::MAX)));
253
+ group.bench_function("ssr_props", |b| {
254
+ b.iter(|| {
255
+ let mut writer = Writer::new(&mut out);
256
+ black_box(&node).write(&mut writer);
257
+ });
258
+ });
259
+ group.finish();
260
+ }
261
+
262
+ fn transcode(c: &mut Criterion) {
263
+ let latin1: Vec<u8> = "Le café de la naïveté à Zürich, déjà vu. "
264
+ .chars()
265
+ .filter_map(|ch| u8::try_from(u32::from(ch)).ok())
266
+ .cycle()
267
+ .take(64 << 10)
268
+ .collect();
269
+ let utf16: Vec<u8> = "東京の天気は晴れです。"
270
+ .encode_utf16()
271
+ .cycle()
272
+ .take(32 << 10)
273
+ .flat_map(u16::to_le_bytes)
274
+ .collect();
275
+ let mut out = vec![0; 192 << 10];
276
+ let mut group = c.benchmark_group("transcode");
277
+ group.throughput(Throughput::Bytes(
278
+ latin1.len().try_into().unwrap_or(u64::MAX),
279
+ ));
280
+ group.bench_function("latin1_to_utf8", |b| {
281
+ b.iter(|| text::latin1_to_utf8_into(black_box(&latin1), &mut out));
282
+ });
283
+ group.throughput(Throughput::Bytes(
284
+ utf16.len().try_into().unwrap_or(u64::MAX),
285
+ ));
286
+ group.bench_function("utf16le_to_utf8", |b| {
287
+ b.iter(|| text::utf16le_to_utf8_into(black_box(&utf16), &mut out));
288
+ });
289
+ group.finish();
290
+ }
291
+
292
+ criterion_group!(benches, decode, write, transcode);
293
+ criterion_main!(benches);
@@ -1,7 +1,6 @@
1
1
  use std::cell::Cell;
2
2
  use std::os::fd::{AsRawFd, IntoRawFd};
3
- use std::sync::OnceLock;
4
- use std::time::Duration;
3
+ use std::time::Instant;
5
4
 
6
5
  use magnus::error::ErrorType;
7
6
  use magnus::gc::Marker;
@@ -14,32 +13,9 @@ use mini_racer_engine::{
14
13
 
15
14
  use crate::encode;
16
15
  use crate::gc_root::OwnedRoot;
16
+ use crate::spin::{self, History};
17
17
  use crate::{context, decode, errors, rb_raw};
18
18
 
19
- struct SpinBudget {
20
- gvl: Duration,
21
- nogvl: Duration,
22
- }
23
-
24
- // Unstable tuning knob: MINI_RACER_RS_SPIN_US=<with GVL>,<without GVL>.
25
- fn budget() -> &'static SpinBudget {
26
- static BUDGET: OnceLock<SpinBudget> = OnceLock::new();
27
- BUDGET.get_or_init(|| {
28
- let setting = std::env::var("MINI_RACER_RS_SPIN_US").unwrap_or_default();
29
- let parts: Vec<Option<u64>> = setting
30
- .split(',')
31
- .map(|part| part.trim().parse().ok())
32
- .collect();
33
- let at = |i: usize, default: u64| {
34
- Duration::from_micros(parts.get(i).copied().flatten().unwrap_or(default))
35
- };
36
- SpinBudget {
37
- gvl: at(0, 20),
38
- nogvl: at(1, 50),
39
- }
40
- })
41
- }
42
-
43
19
  /// What a context's callbacks run: its attached callables by id, and where
44
20
  /// the exception the latest callback raised is kept, as `mini_racer` keeps one
45
21
  /// per context and clears it after every dispatch, nested ones included.
@@ -69,6 +45,7 @@ pub struct RubyHost<'r> {
69
45
  pub struct Waiter {
70
46
  io: OwnedRoot<Value>,
71
47
  epoch: Cell<u64>,
48
+ history: History,
72
49
  }
73
50
 
74
51
  impl Waiter {
@@ -268,28 +245,44 @@ fn message(ruby: &Ruby, exception: Exception) -> JsText {
268
245
 
269
246
  impl Host for RubyHost<'_> {
270
247
  fn wait(&mut self, wait: &WaitHandle<'_>) -> WaitOutcome {
271
- let budget = budget();
272
- if wait.spin(budget.gvl) {
273
- return WaitOutcome::Ready;
248
+ let budget = spin::budget();
249
+ let history = self.waiter.map(|waiter| &waiter.history);
250
+ let spins = history.is_none_or(History::spins);
251
+ // A wait that does not spin times itself to tell whether spinning
252
+ // would have caught the answer.
253
+ let started = (!spins).then(Instant::now);
254
+ let answered = |spun: bool| {
255
+ if let Some(history) = history {
256
+ history.record(started.map_or(spun, |at| at.elapsed() <= budget.total()));
257
+ }
258
+ WaitOutcome::Ready
259
+ };
260
+ if spins && wait.spin(budget.gvl) {
261
+ return answered(true);
274
262
  }
275
263
  if rb_raw::fiber_scheduler(self.ruby)
276
264
  && let Some(outcome) = self.wait_in_fiber(wait)
277
265
  {
278
- return outcome;
266
+ return match outcome {
267
+ WaitOutcome::Ready => answered(false),
268
+ WaitOutcome::Cancel => outcome,
269
+ };
279
270
  }
280
271
  let unparker = wait.unparker();
281
272
  loop {
282
273
  let parked = rb_raw::nogvl(
283
274
  self.ruby,
284
275
  || {
285
- if !wait.spin(budget.nogvl) {
276
+ let spun = spins && wait.spin(budget.nogvl);
277
+ if !spun {
286
278
  wait.park();
287
279
  }
280
+ spun
288
281
  },
289
282
  &move || unparker.unpark(),
290
283
  );
291
284
  if wait.ready() {
292
- return WaitOutcome::Ready;
285
+ return answered(matches!(parked, Some(Ok(true))));
293
286
  }
294
287
  if parked.as_ref().is_some_and(Result::is_err) {
295
288
  self.pending = Some(errors::internal(self.ruby, "panic while waiting"));
@@ -19,6 +19,7 @@ mod platform;
19
19
  mod rb_raw;
20
20
  mod snapshot;
21
21
  mod source_maps;
22
+ mod spin;
22
23
 
23
24
  use magnus::{Error, Module, Ruby};
24
25
 
@@ -0,0 +1,123 @@
1
+ use std::cell::Cell;
2
+ use std::sync::OnceLock;
3
+ use std::time::Duration;
4
+
5
+ /// How long a Ruby thread spins for a runner's answer before it gives up the
6
+ /// GVL, and then before it parks.
7
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
8
+ pub struct Budget {
9
+ pub gvl: Duration,
10
+ pub nogvl: Duration,
11
+ }
12
+
13
+ impl Budget {
14
+ fn parse(setting: &str) -> Self {
15
+ let parts: Vec<Option<u64>> = setting
16
+ .split(',')
17
+ .map(|part| part.trim().parse().ok())
18
+ .collect();
19
+ let at = |i: usize, default: u64| {
20
+ Duration::from_micros(parts.get(i).copied().flatten().unwrap_or(default))
21
+ };
22
+ Self {
23
+ gvl: at(0, 20),
24
+ nogvl: at(1, 50),
25
+ }
26
+ }
27
+
28
+ pub const fn total(&self) -> Duration {
29
+ self.gvl.saturating_add(self.nogvl)
30
+ }
31
+ }
32
+
33
+ // Unstable tuning knob: MINI_RACER_RS_SPIN_US=<with GVL>,<without GVL>.
34
+ pub fn budget() -> &'static Budget {
35
+ static BUDGET: OnceLock<Budget> = OnceLock::new();
36
+ BUDGET
37
+ .get_or_init(|| Budget::parse(&std::env::var("MINI_RACER_RS_SPIN_US").unwrap_or_default()))
38
+ }
39
+
40
+ // A context whose answers outlast the budget, such as one rendering pages,
41
+ // gains nothing from spinning: each wait burns the whole budget on the
42
+ // caller's CPU, the first part holding the GVL from other threads, and then
43
+ // parks anyway. A few in a row are enough to tell it from a one-off.
44
+ const STREAK: u8 = 3;
45
+
46
+ /// One context's recent waits: after `STREAK` in a row outlast the budget its
47
+ /// waits park at once, until one is answered within the budget.
48
+ #[derive(Debug, Default)]
49
+ pub struct History {
50
+ slow: Cell<u8>,
51
+ }
52
+
53
+ impl History {
54
+ pub const fn spins(&self) -> bool {
55
+ self.slow.get() < STREAK
56
+ }
57
+
58
+ pub fn record(&self, within_budget: bool) {
59
+ let slow = if within_budget {
60
+ 0
61
+ } else {
62
+ self.slow.get().saturating_add(1).min(STREAK)
63
+ };
64
+ self.slow.set(slow);
65
+ }
66
+ }
67
+
68
+ #[cfg(test)]
69
+ mod tests {
70
+ use super::*;
71
+
72
+ #[test]
73
+ fn the_budget_defaults_and_reads_the_knob() {
74
+ let us = Duration::from_micros;
75
+ assert_eq!(
76
+ Budget::parse(""),
77
+ Budget {
78
+ gvl: us(20),
79
+ nogvl: us(50)
80
+ }
81
+ );
82
+ assert_eq!(
83
+ Budget::parse("0,0"),
84
+ Budget {
85
+ gvl: us(0),
86
+ nogvl: us(0)
87
+ }
88
+ );
89
+ assert_eq!(
90
+ Budget::parse("5, x"),
91
+ Budget {
92
+ gvl: us(5),
93
+ nogvl: us(50)
94
+ }
95
+ );
96
+ assert_eq!(Budget::parse("").total(), us(70));
97
+ }
98
+
99
+ #[test]
100
+ fn a_streak_of_slow_answers_stops_spinning() {
101
+ let history = History::default();
102
+ for _ in 1..STREAK {
103
+ history.record(false);
104
+ assert!(history.spins());
105
+ }
106
+ history.record(false);
107
+ assert!(!history.spins());
108
+ history.record(false);
109
+ assert!(!history.spins());
110
+ }
111
+
112
+ #[test]
113
+ fn one_answer_within_the_budget_brings_spinning_back() {
114
+ let history = History::default();
115
+ for _ in 0..STREAK {
116
+ history.record(false);
117
+ }
118
+ history.record(true);
119
+ assert!(history.spins());
120
+ history.record(false);
121
+ assert!(history.spins());
122
+ }
123
+ }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MiniRacerRs
4
- VERSION = "1.1.0"
4
+ VERSION = "1.1.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mini_racer_rs
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Aragon
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-10-19 00:00:00.000000000 Z
10
+ date: 2026-10-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rb_sys
@@ -82,6 +82,7 @@ files:
82
82
  - crates/mini_racer_sync/src/termination.rs
83
83
  - crates/mini_racer_sync/src/workers.rs
84
84
  - crates/mini_racer_wire/Cargo.toml
85
+ - crates/mini_racer_wire/benches/wire.rs
85
86
  - crates/mini_racer_wire/src/decode.rs
86
87
  - crates/mini_racer_wire/src/lib.rs
87
88
  - crates/mini_racer_wire/src/marker.rs
@@ -104,6 +105,7 @@ files:
104
105
  - ext/mini_racer_rs/src/rb_raw.rs
105
106
  - ext/mini_racer_rs/src/snapshot.rs
106
107
  - ext/mini_racer_rs/src/source_maps.rs
108
+ - ext/mini_racer_rs/src/spin.rs
107
109
  - ext/mini_racer_rs/v8-archives.sha256
108
110
  - lib/mini_racer.rb
109
111
  - lib/mini_racer/shared.rb