From cef466ce5313724df2d657f76969596b9aa31703 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Wed, 24 Jun 2026 15:32:59 +1000 Subject: [PATCH 1/3] FIX: support clang 22+ compilation stop relying on mismatched uint64_t / unsigned long --- CHANGELOG | 4 ++++ .../mini_racer_extension.c | 2 +- ext/mini_racer_extension/serde.c | 22 +++++++++++-------- test/mini_racer_test.rb | 22 +++++++++++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 2a4b8cd..c684907 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +- vNext + - Fix building with Clang 22+ by making bigint serialization byte-oriented instead of relying on mismatched `uint64_t`/`unsigned long` pointer types + - Fix Ruby bigint serialization to consider all packed limbs instead of truncating values above 512 bits + - 0.22.0 - 12-08-2026 - Add `Context#call_await` and `Context#eval_await`: like `call`/`eval` but block until a returned Promise settles and return the settled value; rejections raise `MiniRacer::RuntimeError` - Fix a `call` or `eval` made from a Ruby callback taking the timeout or `stop` meant for the evaluation around it, which then kept running diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 14dec60..26f3ae5 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -677,7 +677,7 @@ static int serialize1(Ser *s, VALUE refs, VALUE v) if (sign < 0) v = rb_big_mul(v, LONG2FIX(-1)); rb_big_pack(v, limbs, countof(limbs)); - ser_bigint(s, limbs, countof(limbs), sign); + ser_bigint(s, limbs, sizeof(limbs), sign); break; case T_FIXNUM: ser_int(s, FIX2LONG(v)); diff --git a/ext/mini_racer_extension/serde.c b/ext/mini_racer_extension/serde.c index 7c0bf45..f0370a9 100644 --- a/ext/mini_racer_extension/serde.c +++ b/ext/mini_racer_extension/serde.c @@ -243,27 +243,31 @@ static void ser_num(Ser *s, double v) } } -// ser_bigint: |n| is in bytes, not quadwords -static void ser_bigint(Ser *s, const uint64_t *p, size_t n, int sign) +// ser_bigint: |p| points to |n| bytes, interpreted as little-endian +// 64-bit words. The buffer may be backed by Ruby's unsigned long limbs or +// V8-style uint64_t words; keep the interface byte-oriented so callers don't +// need to agree on the concrete typedef used for a 64-bit word. +static void ser_bigint(Ser *s, const void *p, size_t n, int sign) { + const uint8_t *bytes; + if (*s->err) return; if (n % 8) { snprintf(s->err, sizeof(s->err), "bad bigint"); return; } + bytes = p; w_byte(s, 'Z'); // chop off high all-zero words - n /= 8; - while (n--) - if (p[n]) - break; - if (n == (size_t)-1) { + while (n > 0 && bytes[n-1] == 0) + n--; + if (n == 0) { w_byte(s, 0); // normalized zero } else { - n = 8*n + 8; + n = (n + 7) & ~(size_t)7; w_varint(s, 2*n + (sign < 0)); - w(s, p, n); + w(s, bytes, n); } } diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index e9ff5c5..9ed98fa 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1681,6 +1681,28 @@ def test_large_integer end end + def test_large_bigint_serialization_uses_all_packed_limbs + if RUBY_ENGINE == "truffleruby" + skip "C extension is not used on TruffleRuby" + end + + [ + 2**64, + -(2**64), + 2**128 + 2**64 + 12_345, + -(2**128 + 2**64 + 12_345), + 2**512, + -(2**512 + 1) + ].each do |big_int| + context = MiniRacer::Context.new + context.attach("test", proc { big_int }) + + assert_equal "bigint", context.eval("typeof test()") + assert_equal big_int.to_s, context.eval("test().toString()") + assert_equal big_int, context.eval("test()") + end + end + def test_uint8array_is_converted_to_string context = MiniRacer::Context.new result = context.eval("new Uint8Array([0, 1, 2, 3])") From c08a0b6d444e5f0e588f1c3c65d286ff121040b6 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 13 Aug 2026 16:44:40 +1000 Subject: [PATCH 2/3] FIX: support large bigint conversion Use Ruby's integer packing APIs to preserve bigint magnitudes and signs across architectures. Handle values up to 16 MiB with bounded temporary storage, reject oversized values cleanly, and cover large Ruby and JavaScript round trips. --- CHANGELOG | 5 +- README.md | 4 + .../mini_racer_extension.c | 79 ++++++++++++------- ext/mini_racer_extension/serde.c | 11 ++- test/mini_racer_test.rb | 72 ++++++++++++++++- 5 files changed, 135 insertions(+), 36 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index c684907..e8bb0a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ - vNext - - Fix building with Clang 22+ by making bigint serialization byte-oriented instead of relying on mismatched `uint64_t`/`unsigned long` pointer types - - Fix Ruby bigint serialization to consider all packed limbs instead of truncating values above 512 bits + - Fix building with Clang 22+ and big-endian bigint serialization by making bigint serialization byte-oriented instead of relying on native `uint64_t`/`unsigned long` representations + - Fix Ruby integers at or above 512 bits being silently truncated when passed to JavaScript, and large JavaScript bigints producing an invalid internal value when returned to Ruby + - Support Ruby and JavaScript bigints up to a 16 MiB magnitude, using allocation-free conversion for common sizes and bounded dynamic storage for larger values - 0.22.0 - 12-08-2026 - Add `Context#call_await` and `Context#eval_await`: like `call`/`eval` but block until a returned Promise settles and return the settled value; rejections raise `MiniRacer::RuntimeError` diff --git a/README.md b/README.md index 5e2faa3..b39bff1 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ puts context.eval("array_and_hash()") # => {"a" => 1, "b" => [1, {"a" => 1}]} ``` +Ruby `Integer` and JavaScript `BigInt` values are converted exactly up to a +16 MiB magnitude (about 134 million bits). Larger individual values are +rejected with a serialization error rather than truncated. + ### Return binary data from Ruby to JavaScript Attached Ruby functions can return binary data as `Uint8Array` using `MiniRacer::Binary`: diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 26f3ae5..83a996f 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -62,6 +62,8 @@ static inline void rb_thread_lock_native_thread(void) #define countof(x) (sizeof(x) / sizeof(*(x))) #define endof(x) ((x) + countof(x)) +#define BIGINT_STACK_WORDS 64 +#define BIGINT_MAX_BYTES (16 * 1024 * 1024) // mostly RO: assigned once by platform_set_flag1 while holding |flags_mtx|, // from then on read-only and accessible without holding locks @@ -353,33 +355,32 @@ static void des_date(void *arg, double v) put(arg, rb_time_new(sec, usec)); } -// note: v8 stores bigints in 1's complement, ruby in 2's complement, -// so we have to take additional steps to ensure correct conversion +// note: v8 stores bigints as a sign plus little-endian 64-bit magnitude words static void des_bigint(void *arg, const void *p, size_t n, int sign) { VALUE v; - size_t i; DesCtx *c; - unsigned long *a, t, limbs[65]; // +1 to suppress sign extension + int flags; c = arg; if (*c->err) return; - if (n > sizeof(limbs) - sizeof(*limbs)) { + if (n % sizeof(uint64_t)) { + snprintf(c->err, sizeof(c->err), "bad bigint"); + return; + } + if (n > BIGINT_MAX_BYTES) { snprintf(c->err, sizeof(c->err), "bigint too big"); return; } - a = limbs; - t = 0; - for (i = 0; i < n; a++, i += sizeof(*a)) { - memcpy(a, (char *)p + i, sizeof(*a)); - t = *a; + if (n == 0) { + v = INT2FIX(0); + } else { + flags = INTEGER_PACK_LITTLE_ENDIAN; + if (sign < 0) + flags |= INTEGER_PACK_NEGATIVE; + v = rb_integer_unpack(p, n/sizeof(uint64_t), sizeof(uint64_t), 0, flags); } - if (t >> 63) - *a++ = 0; // suppress sign extension - v = rb_big_unpack(limbs, a-limbs); - if (sign < 0) - v = rb_funcall(v, rb_intern("-@"), 0); put(c, v); } @@ -580,12 +581,42 @@ static void add_string(Ser *s, VALUE v) return ser_string(s, p, n); } +// Keep small values allocation-free while allowing large values up to a +// deliberate per-value limit that bounds temporary conversion storage. +static int serialize_bigint(Ser *s, VALUE v) +{ + uint64_t stack_words[BIGINT_STACK_WORDS]; + uint64_t *words; + size_t nwords, nbytes; + int packed; + + nwords = rb_absint_numwords(v, 64, NULL); + if (nwords == (size_t)-1 || nwords > BIGINT_MAX_BYTES/sizeof(*words)) + return bail(&s->err, "bigint too big"); + nbytes = nwords * sizeof(*words); + words = stack_words; + if (nwords > countof(stack_words)) { + words = malloc(nbytes); + if (!words) + return bail(&s->err, "out of memory"); + } + packed = rb_integer_pack(v, words, nwords, sizeof(*words), 0, + INTEGER_PACK_LITTLE_ENDIAN); + if (packed < -1 || packed > 1) { + if (words != stack_words) + free(words); + return bail(&s->err, "bigint too big"); + } + ser_bigint(s, words, nbytes, packed < 0 ? -1 : 1); + if (words != stack_words) + free(words); + return *s->err ? -1 : 0; +} + static int serialize1(Ser *s, VALUE refs, VALUE v) { - unsigned long limbs[64]; VALUE a, t, id; size_t i, n; - int sign; if (*s->err) return -1; @@ -670,15 +701,7 @@ static int serialize1(Ser *s, VALUE refs, VALUE v) ser_bool(s, 0); break; case T_BIGNUM: - // note: v8 stores bigints in 1's complement, ruby in 2's complement, - // so we have to take additional steps to ensure correct conversion - memset(limbs, 0, sizeof(limbs)); - sign = rb_big_sign(v) ? 1 : -1; - if (sign < 0) - v = rb_big_mul(v, LONG2FIX(-1)); - rb_big_pack(v, limbs, countof(limbs)); - ser_bigint(s, limbs, sizeof(limbs), sign); - break; + return serialize_bigint(s, v); case T_FIXNUM: ser_int(s, FIX2LONG(v)); break; @@ -958,6 +981,8 @@ static VALUE deserialize1(DesCtx *d, const uint8_t *p, size_t n) if (des(&err, p, n, d)) rb_raise(runtime_error, "%s", err); + if (*d->err) + rb_raise(runtime_error, "%s", d->err); if (d->tos != d->stack) // should not happen rb_raise(runtime_error, "parse stack not empty"); return d->tos->a; @@ -1020,7 +1045,7 @@ static void *rendezvous_callback(void *arg) goto fail; } ser_init1(&s, 'c'); // callback reply - if (serialize(&s, r)) { // should not happen + if (serialize(&s, r)) { c->exception = rb_exc_new_cstr(internal_error, s.err); ser_reset(&s); goto fail; diff --git a/ext/mini_racer_extension/serde.c b/ext/mini_racer_extension/serde.c index f0370a9..8f86a8e 100644 --- a/ext/mini_racer_extension/serde.c +++ b/ext/mini_racer_extension/serde.c @@ -244,9 +244,8 @@ static void ser_num(Ser *s, double v) } // ser_bigint: |p| points to |n| bytes, interpreted as little-endian -// 64-bit words. The buffer may be backed by Ruby's unsigned long limbs or -// V8-style uint64_t words; keep the interface byte-oriented so callers don't -// need to agree on the concrete typedef used for a 64-bit word. +// 64-bit words. Keep the interface byte-oriented so callers don't need to +// expose a concrete word type. static void ser_bigint(Ser *s, const void *p, size_t n, int sign) { const uint8_t *bytes; @@ -273,7 +272,9 @@ static void ser_bigint(Ser *s, const void *p, size_t n, int sign) static void ser_int(Ser *s, int64_t v) { + uint8_t bytes[8]; uint64_t t; + size_t i; int sign; if (*s->err) @@ -283,8 +284,10 @@ static void ser_int(Ser *s, int64_t v) if (v <= INT64_MAX/1024) return ser_num(s, v); t = v < 0 ? (uint64_t)(-(v + 1)) + 1 : (uint64_t)v; + for (i = 0; i < sizeof(bytes); i++) + bytes[i] = t >> (8*i); sign = v < 0 ? -1 : 1; - ser_bigint(s, &t, sizeof(t), sign); + ser_bigint(s, bytes, sizeof(bytes), sign); } else { w_byte(s, 'I'); w_zigzag(s, v); diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 9ed98fa..3ff957a 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1681,18 +1681,43 @@ def test_large_integer end end + def test_fixnum_bigint_serialization + if RUBY_ENGINE == "truffleruby" + skip "C extension is not used on TruffleRuby" + end + + [-(2**62), (2**62) - 1].each do |integer| + context = MiniRacer::Context.new + context.attach("test", proc { integer }) + + assert_equal "bigint", context.eval("typeof test()") + assert_equal integer.to_s, context.eval("test().toString()") + assert_equal integer, context.eval("test()") + end + end + def test_large_bigint_serialization_uses_all_packed_limbs if RUBY_ENGINE == "truffleruby" skip "C extension is not used on TruffleRuby" end [ + (2**64) - 1, + -((2**64) - 1), 2**64, -(2**64), - 2**128 + 2**64 + 12_345, - -(2**128 + 2**64 + 12_345), + (2**128) + (2**64) + 12_345, + -((2**128) + (2**64) + 12_345), 2**512, - -(2**512 + 1) + -((2**512) + 1), + (2**1024) + (2**512) + 1, + -((2**1024) + (2**512) + 1), + (2**4095) + (2**2048) + 17, + -((2**4095) + (2**2048) + 17), + (2**4096) + (2**2048) + 17, + -((2**4096) + (2**2048) + 17), + (2**32_768) + (2**16_384) + 17, + -((2**32_768) + (2**16_384) + 17) ].each do |big_int| context = MiniRacer::Context.new context.attach("test", proc { big_int }) @@ -1703,6 +1728,47 @@ def test_large_bigint_serialization_uses_all_packed_limbs end end + def test_v8_bigint_deserialization_handles_zero_and_large_nested_values + if RUBY_ENGINE == "truffleruby" + skip "C extension is not used on TruffleRuby" + end + + context = MiniRacer::Context.new + expected = (2**32_768) + (2**16_384) + 17 + + assert_equal 0, context.eval("0n") + assert_equal((2**64) - 1, context.eval("(2n ** 64n) - 1n")) + assert_equal expected, context.eval("(2n ** 32768n) + (2n ** 16384n) + 17n") + assert_equal( + -expected, + context.eval("-((2n ** 32768n) + (2n ** 16384n) + 17n)") + ) + assert_equal [expected], + context.eval("[(2n ** 32768n) + (2n ** 16384n) + 17n]") + end + + def test_bigint_bridge_rejects_values_larger_than_dynamic_limit + if RUBY_ENGINE == "truffleruby" + skip "C extension is not used on TruffleRuby" + end + + context = MiniRacer::Context.new + max_bigint_bytes = 16 * 1024 * 1024 # BIGINT_MAX_BYTES in the C extension + too_big = 2**((max_bigint_bytes * 8) + 1) + context.attach("test", proc { too_big }) + + error = assert_raises(MiniRacer::InternalError) { context.eval("test()") } + assert_equal "bigint too big", error.message + assert_equal 2, context.eval("1 + 1") + + error = + assert_raises(MiniRacer::RuntimeError) do + context.eval("2n ** #{(max_bigint_bytes * 8) + 1}n") + end + assert_equal "bigint too big", error.message + assert_equal 2, context.eval("1 + 1") + end + def test_uint8array_is_converted_to_string context = MiniRacer::Context.new result = context.eval("new Uint8Array([0, 1, 2, 3])") From e9be6d8c4ea6ff5d2b1e81b56e8590681cf6e973 Mon Sep 17 00:00:00 2001 From: Sam Saffron Date: Thu, 13 Aug 2026 16:47:44 +1000 Subject: [PATCH 3/3] correct CI on Mac --- test/mini_racer_test.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 3ff957a..ae9eae7 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1754,7 +1754,8 @@ def test_bigint_bridge_rejects_values_larger_than_dynamic_limit context = MiniRacer::Context.new max_bigint_bytes = 16 * 1024 * 1024 # BIGINT_MAX_BYTES in the C extension - too_big = 2**((max_bigint_bytes * 8) + 1) + first_rejected_bit = max_bigint_bytes * 8 + too_big = 1 << first_rejected_bit context.attach("test", proc { too_big }) error = assert_raises(MiniRacer::InternalError) { context.eval("test()") } @@ -1763,7 +1764,7 @@ def test_bigint_bridge_rejects_values_larger_than_dynamic_limit error = assert_raises(MiniRacer::RuntimeError) do - context.eval("2n ** #{(max_bigint_bytes * 8) + 1}n") + context.eval("1n << #{first_rejected_bit}n") end assert_equal "bigint too big", error.message assert_equal 2, context.eval("1 + 1")