Skip to content

Commit 18ae38b

Browse files
authored
V8: write to module logs on exception (#3518)
# Description of Changes Fixes #3497. ``` 2025-10-28T15:33:26.846107Z PANIC: test spacetimedb_module:2610: Uncaught Error: i don't want to be called in spacetimedb_module:2610:8 :: bar in spacetimedb_module:2607:2 :: <anonymous> in spacetimedb_module:2256:30 :: __call_reducer__ ``` # API and ABI breaking changes None # Expected complexity level and risk 1 # Testing - Confirmed manually that it works by creating a module that throws an error. See the printout above.
1 parent c58a7d1 commit 18ae38b

5 files changed

Lines changed: 127 additions & 77 deletions

File tree

crates/core/src/host/instance_env.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::db::relational_db::{MutTx, RelationalDB};
44
use crate::error::{DBError, DatastoreError, IndexError, NodesError};
55
use crate::host::wasm_common::TimingSpan;
66
use crate::replica_context::ReplicaContext;
7+
use chrono::{DateTime, Utc};
78
use core::mem;
89
use parking_lot::{Mutex, MutexGuard};
910
use smallvec::SmallVec;
@@ -222,7 +223,7 @@ impl InstanceEnv {
222223
}
223224

224225
let record = Record {
225-
ts: chrono::Utc::now(),
226+
ts: Self::now_for_logging(),
226227
target: None,
227228
filename: None,
228229
line_number: None,
@@ -232,6 +233,12 @@ impl InstanceEnv {
232233
self.console_log(LogLevel::Info, &record, &Noop);
233234
}
234235

236+
/// Returns the current time suitable for logging.
237+
pub fn now_for_logging() -> DateTime<Utc> {
238+
// TODO: figure out whether to use walltime now or logical reducer now (env.reducer_start).
239+
chrono::Utc::now()
240+
}
241+
235242
/// Project `cols` in `row_ref` encoded in BSATN to `buffer`
236243
/// and return the full length of the BSATN.
237244
///

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

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
use super::de::scratch_buf;
44
use super::serialize_to_js;
55
use super::string::IntoJsString;
6-
use crate::database_logger::{BacktraceFrame, BacktraceProvider, ModuleBacktrace};
6+
use crate::{
7+
database_logger::{BacktraceFrame, BacktraceProvider, LogLevel, ModuleBacktrace, Record},
8+
host::instance_env::InstanceEnv,
9+
replica_context::ReplicaContext,
10+
};
711
use core::fmt;
812
use spacetimedb_sats::Serialize;
913
use v8::{tc_scope, Exception, HandleScope, Local, PinScope, PinnedRef, StackFrame, StackTrace, TryCatch, Value};
@@ -228,9 +232,7 @@ pub(super) struct JsError {
228232
impl fmt::Display for JsError {
229233
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230234
writeln!(f, "js error {}", self.msg)?;
231-
if !f.alternate() {
232-
writeln!(f, "{}", self.trace)?;
233-
}
235+
writeln!(f, "{}", self.trace)?;
234236
Ok(())
235237
}
236238
}
@@ -272,17 +274,32 @@ impl fmt::Display for JsStackTrace {
272274

273275
impl BacktraceProvider for JsStackTrace {
274276
fn capture(&self) -> Box<dyn ModuleBacktrace> {
275-
Box::new(self.clone())
277+
let trace = self
278+
.frames
279+
.iter()
280+
.map(|f| {
281+
(
282+
format!("{}:{}:{}", f.script_name(), f.line, f.column),
283+
f.fn_name().to_owned(),
284+
)
285+
})
286+
.collect();
287+
Box::new(JsBacktrace { trace })
276288
}
277289
}
278290

279-
impl ModuleBacktrace for JsStackTrace {
291+
/// A rendered backtrace for a JS exception.
292+
struct JsBacktrace {
293+
trace: Vec<(String, String)>,
294+
}
295+
296+
impl ModuleBacktrace for JsBacktrace {
280297
fn frames(&self) -> Vec<BacktraceFrame<'_>> {
281-
self.frames
298+
self.trace
282299
.iter()
283-
.map(|frame| BacktraceFrame {
284-
module_name: frame.script_name.as_deref(),
285-
func_name: frame.fn_name.as_deref(),
300+
.map(|(module_name, func_name)| BacktraceFrame {
301+
module_name: Some(module_name),
302+
func_name: Some(func_name),
286303
})
287304
.collect()
288305
}
@@ -388,13 +405,28 @@ impl JsError {
388405
}
389406
}
390407

391-
pub(super) fn log_traceback(func_type: &str, func: &str, e: &anyhow::Error) {
392-
log::info!("{func_type} \"{func}\" runtime error: {e:#}");
408+
pub(super) fn log_traceback(replica_ctx: &ReplicaContext, func_type: &str, func: &str, e: &anyhow::Error) {
409+
log::info!("{func_type} \"{func}\" runtime error: {e:}");
393410
if let Some(js_err) = e.downcast_ref::<JsError>() {
394411
log::info!("js error {}", js_err.msg);
395412
for (index, frame) in js_err.trace.frames.iter().enumerate() {
396413
log::info!(" Frame #{index}: {frame}");
397414
}
415+
416+
// Also log to module logs.
417+
let first_frame = js_err.trace.frames.first();
418+
let filename = first_frame.map(|f| f.script_name());
419+
let line_number = first_frame.map(|f| f.line as u32);
420+
let message = &js_err.msg;
421+
let record = Record {
422+
ts: InstanceEnv::now_for_logging(),
423+
target: None,
424+
filename,
425+
line_number,
426+
function: Some(func),
427+
message,
428+
};
429+
replica_ctx.logger.write(LogLevel::Panic, &record, &js_err.trace);
398430
}
399431
}
400432

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

Lines changed: 73 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ fn startup_instance_worker<'scope>(
333333
let module_common = match module_or_mcc {
334334
Either::Left(module_common) => Either::Left(module_common),
335335
Either::Right(mcc) => {
336-
let def = extract_description(scope)?;
336+
let def = extract_description(scope, &mcc.replica_ctx)?;
337337

338338
// Validate and create a common module from the raw definition.
339339
Either::Right(build_common_module_from_raw(mcc, def)?)
@@ -577,72 +577,84 @@ fn call_reducer<'scope>(
577577
) -> (super::ReducerCallResult, bool) {
578578
let mut trapped = false;
579579

580-
let (res, _) = instance_common.call_reducer_with_tx(replica_ctx, tx, params, log_traceback, |tx, op, budget| {
581-
// TODO(v8): Start the budget timeout and long-running logger.
582-
let env = env_on_isolate_unwrap(scope);
583-
let mut tx_slot = env.instance_env.tx.clone();
584-
585-
// Start the timer.
586-
// We'd like this tightly around `__call_reducer__`.
587-
env.start_reducer(op.name, op.timestamp);
588-
589-
// Call `__call_reducer__` with `tx` provided.
590-
// It should not be available before.
591-
let (tx, call_result) = tx_slot.set(tx, || {
592-
catch_exception(scope, |scope| {
593-
let res = call_call_reducer(scope, fun, op)?;
594-
Ok(res)
595-
})
596-
.map_err(|(e, can_continue)| {
597-
// Convert `can_continue` to whether the isolate has "trapped".
598-
// Also cancel execution termination if needed,
599-
// that can occur due to terminating long running reducers.
600-
trapped = match can_continue {
601-
CanContinue::No => false,
602-
CanContinue::Yes => true,
603-
CanContinue::YesCancelTermination => {
604-
scope.cancel_terminate_execution();
605-
true
606-
}
607-
};
608-
609-
e
610-
})
611-
.map_err(anyhow::Error::from)
612-
});
613-
614-
// Finish timings.
615-
let timings = env_on_isolate_unwrap(scope).finish_reducer();
616-
617-
// Derive energy stats.
618-
let energy = energy_from_elapsed(budget, timings.total_duration);
619-
620-
// Fetch the currently used heap size in V8.
621-
// The used size is ostensibly fairer than the total size.
622-
let memory_allocation = scope.get_heap_statistics().used_heap_size();
580+
let (res, _) = instance_common.call_reducer_with_tx(
581+
replica_ctx,
582+
tx,
583+
params,
584+
move |a, b, c| log_traceback(replica_ctx, a, b, c),
585+
|tx, op, budget| {
586+
// TODO(v8): Start the budget timeout and long-running logger.
587+
let env = env_on_isolate_unwrap(scope);
588+
let mut tx_slot = env.instance_env.tx.clone();
589+
590+
// Start the timer.
591+
// We'd like this tightly around `__call_reducer__`.
592+
env.start_reducer(op.name, op.timestamp);
593+
594+
// Call `__call_reducer__` with `tx` provided.
595+
// It should not be available before.
596+
let (tx, call_result) = tx_slot.set(tx, || {
597+
catch_exception(scope, |scope| {
598+
let res = call_call_reducer(scope, fun, op)?;
599+
Ok(res)
600+
})
601+
.map_err(|(e, can_continue)| {
602+
// Convert `can_continue` to whether the isolate has "trapped".
603+
// Also cancel execution termination if needed,
604+
// that can occur due to terminating long running reducers.
605+
trapped = match can_continue {
606+
CanContinue::No => false,
607+
CanContinue::Yes => true,
608+
CanContinue::YesCancelTermination => {
609+
scope.cancel_terminate_execution();
610+
true
611+
}
612+
};
623613

624-
let exec_result = ExecuteResult {
625-
energy,
626-
timings,
627-
memory_allocation,
628-
call_result,
629-
};
630-
(tx, exec_result)
631-
});
614+
e
615+
})
616+
.map_err(anyhow::Error::from)
617+
});
618+
619+
// Finish timings.
620+
let timings = env_on_isolate_unwrap(scope).finish_reducer();
621+
622+
// Derive energy stats.
623+
let energy = energy_from_elapsed(budget, timings.total_duration);
624+
625+
// Fetch the currently used heap size in V8.
626+
// The used size is ostensibly fairer than the total size.
627+
let memory_allocation = scope.get_heap_statistics().used_heap_size();
628+
629+
let exec_result = ExecuteResult {
630+
energy,
631+
timings,
632+
memory_allocation,
633+
call_result,
634+
};
635+
(tx, exec_result)
636+
},
637+
);
632638

633639
(res, trapped)
634640
}
635641

636642
/// Extracts the raw module def by running the registered `__describe_module__` hook.
637-
fn extract_description<'scope>(scope: &mut PinScope<'scope, '_>) -> Result<RawModuleDef, DescribeError> {
638-
run_describer(log_traceback, || {
639-
catch_exception(scope, |scope| {
640-
let def = call_describe_module(scope)?;
641-
Ok(def)
642-
})
643-
.map_err(|(e, _)| e)
644-
.map_err(Into::into)
645-
})
643+
fn extract_description<'scope>(
644+
scope: &mut PinScope<'scope, '_>,
645+
replica_ctx: &ReplicaContext,
646+
) -> Result<RawModuleDef, DescribeError> {
647+
run_describer(
648+
|a, b, c| log_traceback(replica_ctx, a, b, c),
649+
|| {
650+
catch_exception(scope, |scope| {
651+
let def = call_describe_module(scope)?;
652+
Ok(def)
653+
})
654+
.map_err(|(e, _)| e)
655+
.map_err(Into::into)
656+
},
657+
)
646658
}
647659
#[cfg(test)]
648660
mod test {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1216,7 +1216,7 @@ fn console_log<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionCallbackA
12161216
let function = env.log_record_function();
12171217
let record = Record {
12181218
// TODO: figure out whether to use walltime now or logical reducer now (env.reducer_start)
1219-
ts: chrono::Utc::now(),
1219+
ts: InstanceEnv::now_for_logging(),
12201220
target: None,
12211221
filename: filename.as_deref(),
12221222
line_number: Some(frame.get_line_number() as u32),

crates/core/src/host/wasmtime/wasm_instance_env.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,8 +1256,7 @@ impl WasmInstanceEnv {
12561256
let function = env.log_record_function();
12571257

12581258
let record = Record {
1259-
// TODO: figure out whether to use walltime now or logical reducer now (env.reducer_start)
1260-
ts: chrono::Utc::now(),
1259+
ts: InstanceEnv::now_for_logging(),
12611260
target: target.as_deref(),
12621261
filename: filename.as_deref(),
12631262
line_number,

0 commit comments

Comments
 (0)