diff --git a/CHANGELOG b/CHANGELOG index 2a4b8cd..e8bb0a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,8 @@ +- vNext + - 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` - 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/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 14dec60..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, countof(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 7c0bf45..8f86a8e 100644 --- a/ext/mini_racer_extension/serde.c +++ b/ext/mini_racer_extension/serde.c @@ -243,33 +243,38 @@ 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. 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; + 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); } } static void ser_int(Ser *s, int64_t v) { + uint8_t bytes[8]; uint64_t t; + size_t i; int sign; if (*s->err) @@ -279,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 e9ff5c5..ae9eae7 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1681,6 +1681,95 @@ 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**512, + -((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 }) + + 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_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 + 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()") } + assert_equal "bigint too big", error.message + assert_equal 2, context.eval("1 + 1") + + error = + assert_raises(MiniRacer::RuntimeError) do + context.eval("1n << #{first_rejected_bit}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])")