Skip to content

Commit 400d475

Browse files
committed
v8: add call_call_reducer with stack traces
1 parent b117747 commit 400d475

4 files changed

Lines changed: 247 additions & 19 deletions

File tree

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

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Utilities for error handling when dealing with V8.
22
3-
use v8::{Exception, HandleScope, Local, TryCatch, Value};
3+
use core::fmt;
4+
use v8::{Exception, HandleScope, Local, StackFrame, StackTrace, TryCatch, Value};
45

56
/// The result of trying to convert a [`Value`] in scope `'scope` to some type `T`.
67
pub(super) type ValueResult<'scope, T> = Result<T, ExceptionValue<'scope>>;
@@ -117,19 +118,134 @@ impl From<ErrorOrException<JsError>> for anyhow::Error {
117118

118119
/// A JS exception turned into an error.
119120
#[derive(thiserror::Error, Debug)]
120-
#[error("js error: {msg:?}")]
121121
pub(super) struct JsError {
122122
msg: String,
123+
trace: JsStackTrace,
124+
}
125+
126+
impl fmt::Display for JsError {
127+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128+
writeln!(f, "js error {}", self.msg)?;
129+
writeln!(f, "{}", self.trace)?;
130+
Ok(())
131+
}
132+
}
133+
134+
/// A V8 stack trace that is independent of a `'scope`.
135+
#[derive(Debug, Default)]
136+
pub(super) struct JsStackTrace {
137+
frames: Box<[JsStackTraceFrame]>,
138+
}
139+
140+
impl JsStackTrace {
141+
/// Converts a V8 [`StackTrace`] into one independent of `'scope`.
142+
fn from_trace<'scope>(scope: &mut HandleScope<'scope>, trace: Local<'scope, StackTrace>) -> Self {
143+
let frames = (0..trace.get_frame_count())
144+
.map(|index| {
145+
let frame = trace.get_frame(scope, index).unwrap();
146+
JsStackTraceFrame::from_frame(scope, frame)
147+
})
148+
.collect::<Box<[_]>>();
149+
Self { frames }
150+
}
151+
}
152+
153+
impl fmt::Display for JsStackTrace {
154+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155+
for frame in self.frames.iter() {
156+
writeln!(f, "\t{frame}")?;
157+
}
158+
159+
Ok(())
160+
}
161+
}
162+
163+
/// A V8 stack trace frame that is independent of a `'scope`.
164+
#[derive(Debug)]
165+
pub(super) struct JsStackTraceFrame {
166+
line: usize,
167+
column: usize,
168+
script_id: usize,
169+
script_name: Option<String>,
170+
fn_name: Option<String>,
171+
is_eval: bool,
172+
is_ctor: bool,
173+
is_wasm: bool,
174+
is_user_js: bool,
175+
}
176+
177+
impl JsStackTraceFrame {
178+
/// Converts a V8 [`StackFrame`] into one independent of `'scope`.
179+
fn from_frame<'scope>(scope: &mut HandleScope<'scope>, frame: Local<'scope, StackFrame>) -> Self {
180+
let script_name = frame
181+
.get_script_name_or_source_url(scope)
182+
.map(|s| s.to_rust_string_lossy(scope));
183+
184+
let fn_name = frame.get_function_name(scope).map(|s| s.to_rust_string_lossy(scope));
185+
186+
Self {
187+
line: frame.get_line_number(),
188+
column: frame.get_column(),
189+
script_id: frame.get_script_id(),
190+
script_name,
191+
fn_name,
192+
is_eval: frame.is_eval(),
193+
is_ctor: frame.is_constructor(),
194+
is_wasm: frame.is_wasm(),
195+
is_user_js: frame.is_user_javascript(),
196+
}
197+
}
198+
}
199+
200+
impl fmt::Display for JsStackTraceFrame {
201+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202+
let fn_name = self.fn_name.as_deref().unwrap_or("<anonymous>");
203+
let script_name = self.script_name.as_deref().unwrap_or("<unknown location>");
204+
205+
// This isn't exactly the same format as chrome uses,
206+
// but it's close enough for now.
207+
// TODO(centril): make it more like chrome in the future.
208+
f.write_fmt(format_args!(
209+
"at {} ({}:{}:{})",
210+
fn_name, script_name, &self.line, &self.column
211+
))?;
212+
213+
if self.is_ctor {
214+
f.write_str(" (constructor)")?;
215+
}
216+
217+
if self.is_eval {
218+
f.write_str(" (eval)")?;
219+
}
220+
221+
if self.is_wasm {
222+
f.write_str(" (wasm)")?;
223+
}
224+
225+
if !self.is_user_js {
226+
f.write_str(" (native)")?;
227+
}
228+
229+
Ok(())
230+
}
123231
}
124232

125233
impl JsError {
126234
/// Turns a caught JS exception in `scope` into a [`JSError`].
127235
fn from_caught(scope: &mut TryCatch<'_, HandleScope<'_>>) -> Self {
128-
let msg = match scope.message() {
129-
Some(msg) => msg.get(scope).to_rust_string_lossy(scope),
130-
None => "unknown error".to_owned(),
131-
};
132-
Self { msg }
236+
match scope.message() {
237+
Some(message) => Self {
238+
trace: message
239+
.get_stack_trace(scope)
240+
.map(|trace| JsStackTrace::from_trace(scope, trace))
241+
.unwrap_or_default(),
242+
msg: message.get(scope).to_rust_string_lossy(scope),
243+
},
244+
None => Self {
245+
trace: JsStackTrace::default(),
246+
msg: "unknown error".to_owned(),
247+
},
248+
}
133249
}
134250
}
135251

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pub(super) struct KeyCache {
2424
value: Option<Global<v8::String>>,
2525
/// The `__describe_module__` property on the global proxy object.
2626
describe_module: Option<Global<v8::String>>,
27+
/// The `__call_reducer__` property on the global proxy object.
28+
call_reducer: Option<Global<v8::String>>,
2729
}
2830

2931
impl KeyCache {
@@ -42,6 +44,11 @@ impl KeyCache {
4244
Self::get_or_create_key(scope, &mut self.describe_module, "__describe_module__")
4345
}
4446

47+
/// Returns the `__call_reducer__` property name.
48+
pub(super) fn call_reducer<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> {
49+
Self::get_or_create_key(scope, &mut self.call_reducer, "__call_reducer__")
50+
}
51+
4552
/// Returns an interned string corresponding to `string`
4653
/// and memoizes the creation on the v8 side.
4754
fn get_or_create_key<'scope>(

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

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
use super::module_common::{build_common_module_from_raw, ModuleCommon};
44
use super::module_host::{CallReducerParams, DynModule, Module, ModuleInfo, ModuleInstance, ModuleRuntime};
55
use super::UpdateDatabaseResult;
6-
use crate::host::v8::error::{exception_already_thrown, Throwable};
6+
use crate::host::v8::error::{exception_already_thrown, ExceptionThrown, Throwable};
7+
use crate::host::v8::ser::serialize_to_js;
78
use crate::host::wasm_common::module_host_actor::InstanceCommon;
9+
use crate::host::ArgsTuple;
810
use crate::{host::Scheduler, module_host_context::ModuleCreationContext, replica_context::ReplicaContext};
911
use anyhow::anyhow;
1012
use de::deserialize_js;
@@ -13,9 +15,9 @@ use from_value::cast;
1315
use key_cache::get_or_create_key_cache;
1416
use spacetimedb_datastore::locking_tx_datastore::MutTxId;
1517
use spacetimedb_datastore::traits::Program;
16-
use spacetimedb_lib::RawModuleDef;
18+
use spacetimedb_lib::{ConnectionId, Identity, RawModuleDef};
1719
use std::sync::{Arc, LazyLock};
18-
use v8::{Function, HandleScope};
20+
use v8::{Function, HandleScope, Local, Value};
1921

2022
mod de;
2123
mod error;
@@ -141,21 +143,66 @@ impl ModuleInstance for JsInstance {
141143
}
142144
}
143145

146+
/// Returns the global property `key`.
147+
fn get_global_property<'scope>(
148+
scope: &mut HandleScope<'scope>,
149+
key: Local<'scope, v8::String>,
150+
) -> Result<Local<'scope, Value>, ExceptionThrown> {
151+
scope
152+
.get_current_context()
153+
.global(scope)
154+
.get(scope, key.into())
155+
.ok_or_else(exception_already_thrown)
156+
}
157+
158+
// Calls the `__call_reducer__` function on the global proxy object.
159+
fn call_call_reducer(
160+
scope: &mut HandleScope<'_>,
161+
reducer_id: u32,
162+
sender: &Identity,
163+
conn_id: &ConnectionId,
164+
timestamp: u64,
165+
reducer_args: &ArgsTuple,
166+
) -> anyhow::Result<Result<(), Box<str>>> {
167+
// Get a cached version of the `__call_reducer__` property.
168+
let key_cache = get_or_create_key_cache(scope);
169+
let call_reducer_key = key_cache.borrow_mut().call_reducer(scope);
170+
171+
catch_exception(scope, |scope| {
172+
// Serialize the arguments.
173+
let reducer_id = serialize_to_js(scope, &reducer_id)?;
174+
let sender = serialize_to_js(scope, &sender.to_u256())?;
175+
let conn_id: v8::Local<'_, v8::Value> = serialize_to_js(scope, &conn_id.to_u128())?;
176+
let timestamp = serialize_to_js(scope, &timestamp)?;
177+
let reducer_args = serialize_to_js(scope, &reducer_args.tuple.elements)?;
178+
let args = &[reducer_id, sender, conn_id, timestamp, reducer_args];
179+
180+
// Get the function on the global proxy object and convert to a function.
181+
let object = get_global_property(scope, call_reducer_key)?;
182+
let fun =
183+
cast!(scope, object, Function, "function export for `__call_reducer__`").map_err(|e| e.throw(scope))?;
184+
185+
// Call the function.
186+
let receiver = v8::undefined(scope).into();
187+
let ret = fun.call(scope, receiver, args).ok_or_else(exception_already_thrown)?;
188+
189+
// Deserialize the user result.
190+
let user_res = deserialize_js(scope, ret)?;
191+
192+
Ok(user_res)
193+
})
194+
.map_err(Into::into)
195+
}
196+
144197
// Calls the `__describe_module__` function on the global proxy object to extract a [`RawModuleDef`].
145198
fn call_describe_module(scope: &mut HandleScope<'_>) -> anyhow::Result<RawModuleDef> {
146199
// Get a cached version of the `__describe_module__` property.
147200
let key_cache = get_or_create_key_cache(scope);
148-
let describe_module_key = key_cache.borrow_mut().describe_module(scope).into();
201+
let describe_module_key = key_cache.borrow_mut().describe_module(scope);
149202

150203
catch_exception(scope, |scope| {
151-
// Get the function on the global proxy object.
152-
let object = scope
153-
.get_current_context()
154-
.global(scope)
155-
.get(scope, describe_module_key)
156-
.ok_or_else(exception_already_thrown)?;
157-
158-
// Convert to a function.
204+
// Get the function on the global proxy object and convert to a function.
205+
let object = get_global_property(scope, describe_module_key)?;
159206
let fun =
160207
cast!(scope, object, Function, "function export for `__describe_module__`").map_err(|e| e.throw(scope))?;
161208

@@ -187,6 +234,63 @@ mod test {
187234
})
188235
}
189236

237+
#[test]
238+
fn call_call_reducer_works() {
239+
let call = |code| {
240+
with_script(code, |scope, _| {
241+
call_call_reducer(
242+
scope,
243+
42,
244+
&Identity::ONE,
245+
&ConnectionId::ZERO,
246+
24,
247+
&ArgsTuple::nullary(),
248+
)
249+
})
250+
};
251+
252+
// Test the trap case.
253+
let ret = call(
254+
r#"
255+
function __call_reducer__(reducer_id, sender, conn_id, timestamp, args) {
256+
throw new Error("foobar");
257+
}
258+
"#,
259+
);
260+
let actual = format!("{}", ret.expect_err("should trap")).replace("\t", " ");
261+
let expected = r#"
262+
js error Uncaught Error: foobar
263+
at __call_reducer__ (<unknown location>:3:23)
264+
"#;
265+
assert_eq!(actual.trim(), expected.trim());
266+
267+
// Test the error case.
268+
let ret = call(
269+
r#"
270+
function __call_reducer__(reducer_id, sender, conn_id, timestamp, args) {
271+
return {
272+
"tag": "err",
273+
"value": "foobar",
274+
};
275+
}
276+
"#,
277+
);
278+
assert_eq!(&*ret.expect("should not trap").expect_err("should error"), "foobar");
279+
280+
// Test the error case.
281+
let ret = call(
282+
r#"
283+
function __call_reducer__(reducer_id, sender, conn_id, timestamp, args) {
284+
return {
285+
"tag": "ok",
286+
"value": {},
287+
};
288+
}
289+
"#,
290+
);
291+
ret.expect("should not trap").expect("should not error");
292+
}
293+
190294
#[test]
191295
fn call_describe_module_works() {
192296
let code = r#"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ pub(in super::super) mod test {
116116
pub(in super::super) fn with_scope<R>(logic: impl FnOnce(&mut HandleScope<'_>) -> R) -> R {
117117
V8Runtime::init_for_test();
118118
let isolate = &mut Isolate::new(<_>::default());
119+
isolate.set_capture_stack_trace_for_uncaught_exceptions(true, 1024);
119120
let scope = &mut HandleScope::new(isolate);
120121
let context = Context::new(scope, Default::default());
121122
let scope = &mut ContextScope::new(scope, context);

0 commit comments

Comments
 (0)