diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4c717b7dbd0..cfa3251bcd6 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -138,7 +138,7 @@ test = ["spacetimedb-commitlog/test", "spacetimedb-datastore/test"] perfmap = [] [dev-dependencies] -spacetimedb-lib = { path = "../lib", features = ["proptest"] } +spacetimedb-lib = { path = "../lib", features = ["proptest", "test"] } spacetimedb-sats = { path = "../sats", features = ["proptest"] } spacetimedb-commitlog = { path = "../commitlog", features = ["test"] } spacetimedb-datastore = { path = "../datastore/", features = ["test"] } diff --git a/crates/core/src/host/v8/de.rs b/crates/core/src/host/v8/de.rs index d2712a38f40..3103cf14095 100644 --- a/crates/core/src/host/v8/de.rs +++ b/crates/core/src/host/v8/de.rs @@ -2,7 +2,7 @@ use super::error::{exception_already_thrown, ExcResult, ExceptionThrown, ExceptionValue, Throwable, TypeError}; use super::from_value::{cast, FromValue}; -use core::cell::RefCell; +use super::key_cache::{get_or_create_key_cache, KeyCache}; use core::fmt; use core::iter::{repeat_n, RepeatN}; use core::marker::PhantomData; @@ -11,20 +11,7 @@ use derive_more::From; use spacetimedb_sats::de::{self, ArrayVisitor, DeserializeSeed, ProductVisitor, SliceVisitor, SumVisitor}; use spacetimedb_sats::{i256, u256}; use std::borrow::{Borrow, Cow}; -use std::rc::Rc; -use v8::{Array, Global, HandleScope, Local, Name, Object, Uint8Array, Value}; - -/// Returns a `KeyCache` for the current `scope`. -/// -/// Creates the cache in the scope if it doesn't exist yet. -pub(super) fn get_or_create_key_cache(scope: &mut HandleScope<'_>) -> Rc> { - let context = scope.get_current_context(); - context.get_slot::>().unwrap_or_else(|| { - let cache = Rc::default(); - context.set_slot(Rc::clone(&cache)); - cache - }) -} +use v8::{Array, HandleScope, Local, Name, Object, Uint8Array, Value}; /// Deserializes a `T` from `val` in `scope`, using `seed` for any context needed. pub(super) fn deserialize_js_seed<'de, T: DeserializeSeed<'de>>( @@ -109,51 +96,6 @@ fn scratch_buf() -> [MaybeUninit; N] { [const { MaybeUninit::uninit() }; N] } -/// A cache for frequently used strings to avoid re-interning them. -#[derive(Default)] -pub(super) struct KeyCache { - /// The `tag` property for sum values in JS. - tag: Option>, - /// The `value` property for sum values in JS. - value: Option>, -} - -impl KeyCache { - /// Returns the `tag` property name. - pub(super) fn tag<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.tag, "tag") - } - - /// Returns the `value` property name. - pub(super) fn value<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.value, "value") - } - - /// Returns an interned string corresponding to `string` - /// and memoizes the creation on the v8 side. - fn get_or_create_key<'scope>( - scope: &mut HandleScope<'scope>, - slot: &mut Option>, - string: &str, - ) -> Local<'scope, v8::String> { - if let Some(s) = &*slot { - v8::Local::new(scope, s) - } else { - let s = v8_interned_string(scope, string); - *slot = Some(v8::Global::new(scope, s)); - s - } - } -} - -// Creates an interned [`v8::String`]. -fn v8_interned_string<'scope>(scope: &mut HandleScope<'scope>, field: &str) -> Local<'scope, v8::String> { - // Internalized v8 strings are significantly faster than "normal" v8 strings - // since v8 deduplicates re-used strings minimizing new allocations - // see: https://github.com/v8/v8/blob/14ac92e02cc3db38131a57e75e2392529f405f2f/include/v8.h#L3165-L3171 - v8::String::new_from_utf8(scope, field.as_ref(), v8::NewStringType::Internalized).unwrap() -} - /// Extracts a reference `&'scope T` from an owned V8 [`Local<'scope, T>`]. /// /// The lifetime `'scope` is that of the [`HandleScope<'scope>`]. @@ -280,6 +222,14 @@ struct ProductAccess<'this, 'scope> { index: usize, } +// Creates an interned [`v8::String`]. +pub(super) fn v8_interned_string<'scope>(scope: &mut HandleScope<'scope>, field: &str) -> Local<'scope, v8::String> { + // Internalized v8 strings are significantly faster than "normal" v8 strings + // since v8 deduplicates re-used strings minimizing new allocations + // see: https://github.com/v8/v8/blob/14ac92e02cc3db38131a57e75e2392529f405f2f/include/v8.h#L3165-L3171 + v8::String::new_from_utf8(scope, field.as_ref(), v8::NewStringType::Internalized).unwrap() +} + /// Normalizes `field` into an interned `v8::String`. pub(super) fn intern_field_name<'scope>( scope: &mut HandleScope<'scope>, diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs index cf6b316dacd..b490af1cf9f 100644 --- a/crates/core/src/host/v8/error.rs +++ b/crates/core/src/host/v8/error.rs @@ -1,6 +1,6 @@ //! Utilities for error handling when dealing with V8. -use v8::{Exception, HandleScope, Local, Value}; +use v8::{Exception, HandleScope, Local, TryCatch, Value}; /// The result of trying to convert a [`Value`] in scope `'scope` to some type `T`. pub(super) type ValueResult<'scope, T> = Result>; @@ -86,3 +86,61 @@ impl<'scope, T: IntoException<'scope>> Throwable<'scope> for T { exception_already_thrown() } } + +/// Either an error outside V8 JS execution, or an exception inside. +#[derive(Debug)] +pub(super) enum ErrorOrException { + Err(anyhow::Error), + Exception(Exc), +} + +impl From for ErrorOrException { + fn from(e: anyhow::Error) -> Self { + Self::Err(e) + } +} + +impl From for ErrorOrException { + fn from(e: ExceptionThrown) -> Self { + Self::Exception(e) + } +} + +impl From> for anyhow::Error { + fn from(err: ErrorOrException) -> Self { + match err { + ErrorOrException::Err(e) => e, + ErrorOrException::Exception(e) => e.into(), + } + } +} + +/// A JS exception turned into an error. +#[derive(thiserror::Error, Debug)] +#[error("js error: {msg:?}")] +pub(super) struct JsError { + msg: String, +} + +impl JsError { + /// Turns a caught JS exception in `scope` into a [`JSError`]. + fn from_caught(scope: &mut TryCatch<'_, HandleScope<'_>>) -> Self { + let msg = match scope.message() { + Some(msg) => msg.get(scope).to_rust_string_lossy(scope), + None => "unknown error".to_owned(), + }; + Self { msg } + } +} + +/// Run `body` within a try-catch context and capture any JS exception thrown as a [`JsError`]. +pub(super) fn catch_exception<'scope, T>( + scope: &mut HandleScope<'scope>, + body: impl FnOnce(&mut HandleScope<'scope>) -> Result>, +) -> Result> { + let scope = &mut TryCatch::new(scope); + body(scope).map_err(|e| match e { + ErrorOrException::Err(e) => ErrorOrException::Err(e), + ErrorOrException::Exception(_) => ErrorOrException::Exception(JsError::from_caught(scope)), + }) +} diff --git a/crates/core/src/host/v8/key_cache.rs b/crates/core/src/host/v8/key_cache.rs new file mode 100644 index 00000000000..4033a358d93 --- /dev/null +++ b/crates/core/src/host/v8/key_cache.rs @@ -0,0 +1,60 @@ +use super::de::v8_interned_string; +use core::cell::RefCell; +use std::rc::Rc; +use v8::{Global, HandleScope, Local}; + +/// Returns a `KeyCache` for the current `scope`. +/// +/// Creates the cache in the scope if it doesn't exist yet. +pub(super) fn get_or_create_key_cache(scope: &mut HandleScope<'_>) -> Rc> { + let context = scope.get_current_context(); + context.get_slot::>().unwrap_or_else(|| { + let cache = Rc::default(); + context.set_slot(Rc::clone(&cache)); + cache + }) +} + +/// A cache for frequently used strings to avoid re-interning them. +#[derive(Default)] +pub(super) struct KeyCache { + /// The `tag` property for sum values in JS. + tag: Option>, + /// The `value` property for sum values in JS. + value: Option>, + /// The `describe_module` property on the global proxy object. + describe_module: Option>, +} + +impl KeyCache { + /// Returns the `tag` property name. + pub(super) fn tag<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { + Self::get_or_create_key(scope, &mut self.tag, "tag") + } + + /// Returns the `value` property name. + pub(super) fn value<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { + Self::get_or_create_key(scope, &mut self.value, "value") + } + + /// Returns the `describe_module` property name. + pub(super) fn describe_module<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { + Self::get_or_create_key(scope, &mut self.describe_module, "describe_module") + } + + /// Returns an interned string corresponding to `string` + /// and memoizes the creation on the v8 side. + fn get_or_create_key<'scope>( + scope: &mut HandleScope<'scope>, + slot: &mut Option>, + string: &str, + ) -> Local<'scope, v8::String> { + if let Some(s) = &*slot { + v8::Local::new(scope, s) + } else { + let s = v8_interned_string(scope, string); + *slot = Some(v8::Global::new(scope, s)); + s + } + } +} diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 7a4b26ff8bb..1fe3ee76090 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1,20 +1,23 @@ -use super::module_host::CallReducerParams; -use crate::{ - host::{ - module_common::{build_common_module_from_raw, ModuleCommon}, - module_host::{DynModule, Module, ModuleInfo, ModuleInstance, ModuleRuntime}, - Scheduler, - }, - module_host_context::ModuleCreationContext, - replica_context::ReplicaContext, -}; +#![allow(dead_code)] + +use super::module_common::{build_common_module_from_raw, ModuleCommon}; +use super::module_host::{CallReducerParams, DynModule, Module, ModuleInfo, ModuleInstance, ModuleRuntime}; +use crate::host::v8::error::{exception_already_thrown, Throwable}; +use crate::{host::Scheduler, module_host_context::ModuleCreationContext, replica_context::ReplicaContext}; use anyhow::anyhow; +use de::deserialize_js; +use error::catch_exception; +use from_value::cast; +use key_cache::get_or_create_key_cache; use spacetimedb_datastore::locking_tx_datastore::MutTxId; +use spacetimedb_lib::RawModuleDef; use std::sync::{Arc, LazyLock}; +use v8::{Function, HandleScope}; mod de; mod error; mod from_value; +mod key_cache; mod ser; mod to_value; @@ -130,3 +133,73 @@ impl ModuleInstance for JsInstance { todo!() } } + +// Calls the `describe_module` function on the global proxy object to extract a [`RawModuleDef`]. +fn call_describe_module(scope: &mut HandleScope<'_>) -> anyhow::Result { + // Get a cached version of the `describe_module` property. + let key_cache = get_or_create_key_cache(scope); + let describe_module_key = key_cache.borrow_mut().describe_module(scope).into(); + + catch_exception(scope, |scope| { + // Get the function on the global proxy object. + let object = scope + .get_current_context() + .global(scope) + .get(scope, describe_module_key) + .ok_or_else(exception_already_thrown)?; + + // Convert to a function. + let fun = + cast!(scope, object, Function, "function export for `describe_module`").map_err(|e| e.throw(scope))?; + + // Call the function. + let receiver = v8::undefined(scope).into(); + let raw_mod_js = fun.call(scope, receiver, &[]).ok_or_else(exception_already_thrown)?; + + // Deserialize the raw module. + let raw_mod: RawModuleDef = deserialize_js(scope, raw_mod_js)?; + Ok(raw_mod) + }) + .map_err(Into::into) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::host::v8::to_value::test::with_scope; + use v8::{Local, Value}; + + fn with_script( + code: &str, + logic: impl for<'scope> FnOnce(&mut HandleScope<'scope>, Local<'scope, Value>) -> R, + ) -> R { + with_scope(|scope| { + let code = v8::String::new(scope, code).unwrap(); + let script_val = v8::Script::compile(scope, code, None).unwrap().run(scope).unwrap(); + logic(scope, script_val) + }) + } + + #[test] + fn call_describe_module_works() { + let code = r#" + function describe_module() { + return { + "tag": "V9", + "value": { + "typespace": { + "types": [], + }, + "tables": [], + "reducers": [], + "types": [], + "misc_exports": [], + "row_level_security": [], + }, + }; + } + "#; + let raw_mod = with_script(code, |scope, _| call_describe_module(scope).unwrap()); + assert_eq!(raw_mod, RawModuleDef::V9(<_>::default())); + } +} diff --git a/crates/core/src/host/v8/ser.rs b/crates/core/src/host/v8/ser.rs index 3a8819e606c..3e1be4eb61e 100644 --- a/crates/core/src/host/v8/ser.rs +++ b/crates/core/src/host/v8/ser.rs @@ -1,7 +1,8 @@ #![allow(dead_code)] -use super::de::{get_or_create_key_cache, intern_field_name, KeyCache}; +use super::de::intern_field_name; use super::error::{exception_already_thrown, ExcResult, ExceptionThrown, RangeError, Throwable, TypeError}; +use super::key_cache::{get_or_create_key_cache, KeyCache}; use super::to_value::ToValue; use derive_more::From; use spacetimedb_sats::{ diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 886e242cbc2..be542dc853a 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -119,6 +119,7 @@ impl TableDesc { } #[derive(Debug, Clone, SpacetimeType)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] pub struct ReducerDef { pub name: Box, @@ -189,6 +190,7 @@ impl_serialize!([] ReducerArgsWithSchema<'_>, (self, ser) => { //WARNING: Change this structure (or any of their members) is an ABI change. #[derive(Debug, Clone, Default, SpacetimeType)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] pub struct RawModuleDefV8 { pub typespace: sats::Typespace, @@ -213,6 +215,7 @@ impl RawModuleDefV8 { /// /// This is what is actually returned by the module when `__describe_module__` is called, serialized to BSATN. #[derive(Debug, Clone, SpacetimeType)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] #[non_exhaustive] pub enum RawModuleDef { @@ -340,12 +343,14 @@ impl TypespaceBuilder for ModuleDefBuilder { // an enum to keep it extensible without breaking abi #[derive(Debug, Clone, SpacetimeType)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] pub enum MiscModuleExport { TypeAlias(TypeAlias), } #[derive(Debug, Clone, SpacetimeType)] +#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] #[sats(crate = crate)] pub struct TypeAlias { pub name: String,