Skip to content

Commit a655225

Browse files
authored
V8: add FromValue, error traits, roundtrip tests (#2971)
1 parent 6979df5 commit a655225

File tree

6 files changed

+309
-4
lines changed

6 files changed

+309
-4
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc b9cebcbfdb32c25a2093f9e860502a771b4fbd22f92cc7a6a72032aed46dd476 # shrinks to x = -9223372036854775809
8+
cc 2480a43bc9d76592946409dbe626d75ddaa94c6f1506e6508e9e4c688ef36cec # shrinks to x = -340282366920938463463374607431768211456

crates/core/src/host/v8/error.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//! Utilities for error handling when dealing with V8.
2+
3+
use v8::{Exception, HandleScope, Local, Value};
4+
5+
/// The result of trying to convert a [`Value`] in scope `'s` to some type `T`.
6+
pub(super) type ValueResult<'s, T> = Result<T, Local<'s, Value>>;
7+
8+
/// Types that can convert into a JS string type.
9+
pub(super) trait IntoJsString {
10+
/// Converts `self` into a JS string.
11+
fn into_string<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, v8::String>;
12+
}
13+
14+
impl IntoJsString for String {
15+
fn into_string<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, v8::String> {
16+
v8::String::new(scope, &self).unwrap()
17+
}
18+
}
19+
20+
/// Error types that can convert into JS exception values.
21+
pub(super) trait IntoException {
22+
/// Converts `self` into a JS exception value.
23+
fn into_exception<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, Value>;
24+
}
25+
26+
/// A type converting into a JS `TypeError` exception.
27+
#[derive(Copy, Clone)]
28+
pub struct TypeError<M>(pub M);
29+
30+
impl<M: IntoJsString> IntoException for TypeError<M> {
31+
fn into_exception<'s>(self, scope: &mut HandleScope<'s>) -> Local<'s, Value> {
32+
let msg = self.0.into_string(scope);
33+
Exception::type_error(scope, msg)
34+
}
35+
}

crates/core/src/host/v8/from_value.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#![allow(dead_code)]
2+
3+
use super::error::{IntoException as _, TypeError, ValueResult};
4+
use bytemuck::{AnyBitPattern, NoUninit};
5+
use spacetimedb_sats::{i256, u256};
6+
use v8::{BigInt, Boolean, HandleScope, Int32, Local, Number, Uint32, Value};
7+
8+
/// Types that a v8 [`Value`] can be converted into.
9+
pub(super) trait FromValue: Sized {
10+
/// Converts `val` in `scope` to `Self` if possible.
11+
fn from_value<'s>(val: Local<'_, Value>, scope: &mut HandleScope<'s>) -> ValueResult<'s, Self>;
12+
}
13+
14+
/// Provides a [`FromValue`] implementation.
15+
macro_rules! impl_from_value {
16+
($ty:ty, ($val:ident, $scope:ident) => $logic:expr) => {
17+
impl FromValue for $ty {
18+
fn from_value<'s>($val: Local<'_, Value>, $scope: &mut HandleScope<'s>) -> ValueResult<'s, Self> {
19+
$logic
20+
}
21+
}
22+
};
23+
}
24+
25+
/// Tries to cast `Value` into `T` or raises a JS exception as a returned `Err` value.
26+
fn try_cast<'a, 'b, T>(
27+
scope: &mut HandleScope<'a>,
28+
val: Local<'b, Value>,
29+
on_err: impl FnOnce(&str) -> String,
30+
) -> ValueResult<'a, Local<'b, T>>
31+
where
32+
Local<'b, T>: TryFrom<Local<'b, Value>>,
33+
{
34+
val.try_cast::<T>()
35+
.map_err(|_| TypeError(on_err(val.type_repr())).into_exception(scope))
36+
}
37+
38+
/// Tries to cast `Value` into `T` or raises a JS exception as a returned `Err` value.
39+
macro_rules! cast {
40+
($scope:expr, $val:expr, $js_ty:ty, $expected:literal $(, $args:expr)* $(,)?) => {{
41+
try_cast::<$js_ty>($scope, $val, |got| format!(concat!("Expected ", $expected, ", got {__got}"), $($args,)* __got = got))
42+
}};
43+
}
44+
pub(super) use cast;
45+
46+
/// Returns a JS exception value indicating that a value overflowed
47+
/// when converting to the type `rust_ty`.
48+
fn value_overflowed<'s>(rust_ty: &str, scope: &mut HandleScope<'s>) -> Local<'s, Value> {
49+
TypeError(format!("Value overflowed `{rust_ty}`")).into_exception(scope)
50+
}
51+
52+
/// Returns a JS exception value indicating that a value underflowed
53+
/// when converting to the type `rust_ty`.
54+
fn value_underflowed<'s>(rust_ty: &str, scope: &mut HandleScope<'s>) -> Local<'s, Value> {
55+
TypeError(format!("Value underflowed `{rust_ty}`")).into_exception(scope)
56+
}
57+
58+
// `FromValue for bool`.
59+
impl_from_value!(bool, (val, scope) => cast!(scope, val, Boolean, "boolean").map(|b| b.is_true()));
60+
61+
// `FromValue for u8, u16, u32, i8, i16, i32`.
62+
macro_rules! int32_from_value {
63+
($js_ty:ty, $rust_ty:ty) => {
64+
impl_from_value!($rust_ty, (val, scope) => {
65+
let num = cast!(scope, val, $js_ty, "number for `{}`", stringify!($rust_ty))?;
66+
num.value().try_into().map_err(|_| value_overflowed(stringify!($rust_ty), scope))
67+
});
68+
}
69+
}
70+
int32_from_value!(Uint32, u8);
71+
int32_from_value!(Uint32, u16);
72+
int32_from_value!(Uint32, u32);
73+
int32_from_value!(Int32, i8);
74+
int32_from_value!(Int32, i16);
75+
int32_from_value!(Int32, i32);
76+
77+
// `FromValue for f32, f64`.
78+
//
79+
// Note that, as per the rust-reference,
80+
// - "Casting from an f64 to an f32 will produce the closest possible f32"
81+
// https://doc.rust-lang.org/reference/expressions/operator-expr.html#r-expr.as.numeric.float-narrowing
82+
macro_rules! float_from_value {
83+
($rust_ty:ty) => {
84+
impl_from_value!($rust_ty, (val, scope) => {
85+
cast!(scope, val, Number, "number for `{}`", stringify!($rust_ty)).map(|n| n.value() as _)
86+
});
87+
}
88+
}
89+
float_from_value!(f32);
90+
float_from_value!(f64);
91+
92+
// `FromValue for u64, i64`.
93+
macro_rules! int64_from_value {
94+
($rust_ty:ty, $conv_method: ident) => {
95+
impl_from_value!($rust_ty, (val, scope) => {
96+
let rust_ty = stringify!($rust_ty);
97+
let bigint = cast!(scope, val, BigInt, "bigint for `{}`", rust_ty)?;
98+
let (val, ok) = bigint.$conv_method();
99+
ok.then_some(val).ok_or_else(|| value_overflowed(rust_ty, scope))
100+
});
101+
}
102+
}
103+
int64_from_value!(u64, u64_value);
104+
int64_from_value!(i64, i64_value);
105+
106+
/// Converts `bigint` into its signnedness and its list of bytes in little-endian,
107+
/// or errors on overflow or unwanted signedness.
108+
///
109+
/// Parameters:
110+
/// - `N` are the number of bytes to accept at most.
111+
/// - `W = N / 8` are the number of words to accept at most.
112+
/// - `UNSIGNED` is `true` if only unsigned integers are accepted.
113+
/// - `rust_ty` is the target type as a string, for errors.
114+
/// - `scope` for any JS exceptions that need to be raised.
115+
/// - `bigint` is the integer to convert.
116+
fn bigint_to_bytes<'s, const N: usize, const W: usize, const UNSIGNED: bool>(
117+
rust_ty: &str,
118+
scope: &mut HandleScope<'s>,
119+
bigint: &BigInt,
120+
) -> ValueResult<'s, (bool, [u8; N])>
121+
where
122+
[[u8; 8]; W]: NoUninit,
123+
[u8; N]: AnyBitPattern,
124+
{
125+
// Read the words.
126+
let mut words = [0u64; W];
127+
let (sign, _) = bigint.to_words_array(&mut words);
128+
129+
if bigint.word_count() > W {
130+
// There's an under-/over-flow if the caller cannot handle that many words.
131+
return Err(if sign {
132+
value_underflowed(rust_ty, scope)
133+
} else {
134+
value_overflowed(rust_ty, scope)
135+
});
136+
}
137+
138+
if sign && UNSIGNED {
139+
// There's an overflow if the caller cannot accept negative numbers.
140+
return Err(value_overflowed(rust_ty, scope));
141+
}
142+
143+
// convert the words to little-endian bytes.
144+
let bytes = bytemuck::must_cast(words.map(|w| w.to_le_bytes()));
145+
Ok((sign, bytes))
146+
}
147+
148+
// `FromValue for u128, u256`.
149+
macro_rules! unsigned_bigint_from_value {
150+
($rust_ty:ty, $bytes:literal, $words:literal) => {
151+
impl_from_value!($rust_ty, (val, scope) => {
152+
let rust_ty = stringify!($rust_ty);
153+
let bigint = cast!(scope, val, v8::BigInt, "bigint for `{}`", rust_ty)?;
154+
if let (val, true) = bigint.u64_value() {
155+
// Fast path.
156+
return Ok(val.into());
157+
}
158+
let (_, bytes) = bigint_to_bytes::<$bytes, $words, true>(rust_ty, scope, &bigint)?;
159+
Ok(Self::from_le_bytes(bytes))
160+
});
161+
};
162+
}
163+
unsigned_bigint_from_value!(u128, 16, 2);
164+
unsigned_bigint_from_value!(u256, 32, 4);
165+
166+
// `FromValue for i128, i256`.
167+
macro_rules! signed_bigint_from_value {
168+
($rust_ty:ty, $bytes:literal, $words:literal) => {
169+
impl_from_value!($rust_ty, (val, scope) => {
170+
let rust_ty = stringify!($rust_ty);
171+
let bigint = cast!(scope, val, v8::BigInt, "bigint for `{}`", rust_ty)?;
172+
if let (val, true) = bigint.i64_value() {
173+
// Fast path.
174+
return Ok(val.into());
175+
}
176+
let (sign, bytes) = bigint_to_bytes::<$bytes, $words, false>(rust_ty, scope, &bigint)?;
177+
let x = Self::from_le_bytes(bytes);
178+
Ok(if sign {
179+
// A negative number, but we have a positive number `x`, so we want `-x`.
180+
// If that's not possible, and as we know there's no underflow, we have `MIN`.
181+
x.checked_neg().unwrap_or(Self::MIN)
182+
} else {
183+
x
184+
})
185+
});
186+
};
187+
}
188+
signed_bigint_from_value!(i128, 16, 2);
189+
signed_bigint_from_value!(i256, 32, 4);

crates/core/src/host/v8/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ use anyhow::anyhow;
1212
use spacetimedb_datastore::locking_tx_datastore::MutTxId;
1313
use std::sync::{Arc, LazyLock};
1414

15+
mod error;
16+
mod from_value;
1517
mod to_value;
1618

1719
/// The V8 runtime, for modules written in e.g., JS or TypeScript.
@@ -26,6 +28,13 @@ impl ModuleRuntime for V8Runtime {
2628
}
2729
}
2830

31+
#[cfg(test)]
32+
impl V8Runtime {
33+
fn init_for_test() {
34+
LazyLock::force(&V8_RUNTIME_GLOBAL);
35+
}
36+
}
37+
2938
static V8_RUNTIME_GLOBAL: LazyLock<V8RuntimeInner> = LazyLock::new(V8RuntimeInner::init);
3039

3140
/// The actual V8 runtime, with initialization of V8.

crates/core/src/host/v8/to_value.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use v8::{BigInt, Boolean, HandleScope, Integer, Local, Number, Value};
77
/// Types that can be converted to a v8-stack-allocated [`Value`].
88
/// The conversion can be done without the possibility for error.
99
pub(super) trait ToValue {
10-
/// Convert `self` within `scope` (a sort of stack management in V8) to a [`Value`].
10+
/// Converts `self` within `scope` (a sort of stack management in V8) to a [`Value`].
1111
fn to_value<'s>(&self, scope: &mut HandleScope<'s>) -> Local<'s, Value>;
1212
}
1313

@@ -65,14 +65,15 @@ where
6565
impl_to_value!(u128, (val, scope) => le_bytes_to_bigint::<16, 2>(scope, false, val.to_le_bytes()));
6666
impl_to_value!(u256, (val, scope) => le_bytes_to_bigint::<32, 4>(scope, false, val.to_le_bytes()));
6767

68+
pub(super) const WORD_MIN: u64 = i64::MIN as u64;
69+
6870
/// Returns `iN::MIN` for `N = 8 * WORDS` as a V8 [`BigInt`].
6971
///
7072
/// Examples:
7173
/// `i64::MIN` becomes `-1 * WORD_MIN * (2^64)^0 = -1 * WORD_MIN`
7274
/// `i128::MIN` becomes `-1 * (0 * (2^64)^0 + WORD_MIN * (2^64)^1) = -1 * WORD_MIN * 2^64`
7375
/// `i256::MIN` becomes `-1 * (0 * (2^64)^0 + 0 * (2^64)^1 + WORD_MIN * (2^64)^2) = -1 * WORD_MIN * (2^128)`
7476
fn signed_min_bigint<'s, const WORDS: usize>(scope: &mut HandleScope<'s>) -> Local<'s, BigInt> {
75-
const WORD_MIN: u64 = i64::MIN as u64;
7677
let words = &mut [0u64; WORDS];
7778
if let [.., last] = words.as_mut_slice() {
7879
*last = WORD_MIN;
@@ -100,3 +101,64 @@ impl_to_value!(i256, (val, scope) => {
100101
None => signed_min_bigint::<4>(scope),
101102
}
102103
});
104+
105+
#[cfg(test)]
106+
mod test {
107+
use super::super::from_value::FromValue;
108+
use super::super::V8Runtime;
109+
use super::*;
110+
use core::fmt::Debug;
111+
use proptest::prelude::*;
112+
use spacetimedb_sats::proptest::{any_i256, any_u256};
113+
use v8::{Context, ContextScope, HandleScope, Isolate};
114+
115+
/// Roundtrips `rust_val` via `ToValue` to the V8 representation
116+
/// and then back via `FromValue`,
117+
/// asserting that it's the same as the passed value.
118+
fn assert_roundtrips<T: ToValue + FromValue + PartialEq + Debug>(rust_val: T) {
119+
// Setup V8 and get a `HandleScope`.
120+
V8Runtime::init_for_test();
121+
let isolate = &mut Isolate::new(<_>::default());
122+
let scope = &mut HandleScope::new(isolate);
123+
let context = Context::new(scope, Default::default());
124+
let scope = &mut ContextScope::new(scope, context);
125+
126+
// Convert to JS and then back.
127+
let js_val = rust_val.to_value(scope);
128+
let rust_val_prime = T::from_value(js_val, scope).unwrap();
129+
130+
// We should end up where we started.
131+
assert_eq!(rust_val, rust_val_prime);
132+
}
133+
134+
proptest! {
135+
#[test] fn test_bool(x: bool) { assert_roundtrips(x); }
136+
137+
#[test] fn test_f32(x: f32) { assert_roundtrips(x); }
138+
#[test] fn test_f64(x: f64) { assert_roundtrips(x); }
139+
140+
#[test] fn test_u8(x: u8) { assert_roundtrips(x); }
141+
#[test] fn test_u16(x: u16) { assert_roundtrips(x); }
142+
#[test] fn test_u32(x: u32) { assert_roundtrips(x); }
143+
#[test] fn test_u64(x: u64) { assert_roundtrips(x); }
144+
#[test] fn test_u128(x: u128) { assert_roundtrips(x); }
145+
#[test] fn test_u256(x in any_u256()) { assert_roundtrips(x); }
146+
147+
#[test] fn test_i8(x: i8) { assert_roundtrips(x); }
148+
#[test] fn test_i16(x: i16) { assert_roundtrips(x); }
149+
#[test] fn test_i32(x: i32) { assert_roundtrips(x); }
150+
#[test] fn test_i64(x: i64) { assert_roundtrips(x); }
151+
#[test] fn test_i128(x: i128) { assert_roundtrips(x); }
152+
#[test] fn test_i256(x in any_i256()) { assert_roundtrips(x); }
153+
}
154+
155+
#[test]
156+
fn test_signed_mins() {
157+
assert_roundtrips(i8::MIN);
158+
assert_roundtrips(i16::MIN);
159+
assert_roundtrips(i32::MIN);
160+
assert_roundtrips(i64::MIN);
161+
assert_roundtrips(i128::MIN);
162+
assert_roundtrips(i256::MIN);
163+
}
164+
}

crates/sats/src/proptest.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,13 @@ fn generate_non_compound<Val: Arbitrary + Into<AlgebraicValue> + 'static>() -> B
103103
any::<Val>().prop_map(Into::into).boxed()
104104
}
105105

106-
fn any_u256() -> impl Strategy<Value = u256> {
106+
/// Generates any `u256`.
107+
pub fn any_u256() -> impl Strategy<Value = u256> {
107108
any::<(u128, u128)>().prop_map(|(hi, lo)| u256::from_words(hi, lo))
108109
}
109110

110-
fn any_i256() -> impl Strategy<Value = i256> {
111+
/// Generates any `i256`.
112+
pub fn any_i256() -> impl Strategy<Value = i256> {
111113
any::<(i128, i128)>().prop_map(|(hi, lo)| i256::from_words(hi, lo))
112114
}
113115

0 commit comments

Comments
 (0)