use std::{
cell::RefCell,
rc::Rc,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::{Duration, Instant},
};
use deno_core::{Extension, JsRuntime, JsRuntimeForSnapshot, OpState, RuntimeOptions, op2, v8};
use serde_json::Value;
use super::{ops::DenoHostContext, watchdog};
use crate::{
host::dyn_context::DynHostContext,
types::{LogEntry, LogLevel, ResourceLimits},
};
fn ensure_v8_platform() {
static V8_PLATFORM: std::sync::Once = std::sync::Once::new();
V8_PLATFORM.call_once(|| {
JsRuntime::init_platform(Some(
v8::new_unprotected_default_platform(0, false).make_shared(),
));
});
}
fn startup_snapshot() -> &'static [u8] {
static SNAPSHOT: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
SNAPSHOT.get_or_init(|| {
let collector = LogCollector {
logs: Arc::new(Mutex::new(Vec::new())),
max_entries: 0,
};
let runtime = JsRuntimeForSnapshot::new(RuntimeOptions {
extensions: vec![make_fraiseql_extension(collector, None)],
..Default::default()
});
Box::leak(runtime.snapshot())
})
}
#[derive(Clone)]
struct LogCollector {
logs: Arc<Mutex<Vec<LogEntry>>>,
max_entries: usize,
}
#[op2(fast)]
#[allow(clippy::inline_always)] #[allow(clippy::needless_pass_by_value)] fn fraiseql_log(state: Rc<RefCell<OpState>>, #[smi] level: u8, #[string] message: String) {
let state = state.borrow();
let collector = state.borrow::<LogCollector>();
let mut logs = collector.logs.lock().expect("log mutex poisoned");
if logs.len() < collector.max_entries {
let log_level = match level {
0 => LogLevel::Debug,
2 => LogLevel::Warn,
3 => LogLevel::Error,
_ => LogLevel::Info,
};
logs.push(LogEntry {
level: log_level,
message,
timestamp: chrono::Utc::now(),
});
}
}
fn make_fraiseql_extension(
collector: LogCollector,
host: Option<Arc<dyn DynHostContext>>,
) -> Extension {
use super::ops;
Extension {
name: "fraiseql",
ops: std::borrow::Cow::Owned(vec![
fraiseql_log(),
ops::fraiseql_query(),
ops::fraiseql_sql_query(),
ops::fraiseql_http_request(),
ops::fraiseql_storage_get(),
ops::fraiseql_storage_put(),
ops::fraiseql_send_email(),
ops::fraiseql_auth_context(),
ops::fraiseql_env_var(),
ops::fraiseql_idempotency_token(),
ops::fraiseql_cursor_get(),
ops::fraiseql_cursor_advance(),
]),
op_state_fn: Some(Box::new(move |state: &mut OpState| {
state.put(collector);
if let Some(host) = host {
state.put(DenoHostContext(host));
}
})),
..Default::default()
}
}
fn wrap_source(source: &str, event_json: &str) -> String {
let inner = source
.replace("export default async function", "const __fn = async function")
.replace("export default async", "const __fn = async")
.replace("export default function", "const __fn = function")
.replace("export default", "const __fn =");
let marker = crate::types::PERMANENT_ERROR_MARKER;
format!(
r#"
{inner}
(async () => {{
try {{
const __event = {event_json};
const __result = await __fn(__event);
globalThis.__fraiseql_result = JSON.stringify(__result);
globalThis.__fraiseql_error = null;
}} catch (e) {{
globalThis.__fraiseql_result = null;
const __permanent = e && e.fraiseqlPermanent === true;
globalThis.__fraiseql_error = (__permanent ? "{marker} " : "") + String(e);
}}
}})();
"#
)
}
pub struct ExecutionResult {
pub value: Value,
pub logs: Vec<LogEntry>,
pub phases: PhaseTimings,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PhaseTimings {
pub tokio_runtime: Duration,
pub isolate: Duration,
pub script: Duration,
pub event_loop: Duration,
pub result: Duration,
pub teardown: Duration,
}
impl PhaseTimings {
#[must_use]
pub fn total(&self) -> Duration {
self.tokio_runtime
+ self.isolate
+ self.script
+ self.event_loop
+ self.result
+ self.teardown
}
}
pub fn run_in_dedicated_thread(
source: &str,
event_value: &Value,
limits: &ResourceLimits,
host: Option<Arc<dyn DynHostContext>>,
) -> Result<ExecutionResult, String> {
ensure_v8_platform();
let logs_arc: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
let collector = LogCollector {
logs: Arc::clone(&logs_arc),
max_entries: limits.max_log_entries,
};
let event_json = serde_json::to_string(event_value).map_err(|e| e.to_string())?;
let wrapped = wrap_source(source, &event_json);
let max_duration = limits.max_duration;
let max_memory_bytes = usize::try_from(limits.max_memory_bytes).unwrap_or(usize::MAX);
let mem_exceeded_run = Arc::new(AtomicBool::new(false));
let timed_out_run = Arc::new(AtomicBool::new(false));
let mut phases = PhaseTimings::default();
let phase_started = Instant::now();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create tokio runtime: {e}"))?;
phases.tokio_runtime = phase_started.elapsed();
let result = rt.block_on(async {
let create_params = v8::CreateParams::default().heap_limits(0, max_memory_bytes);
let phase_started = Instant::now();
let mut js_runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![make_fraiseql_extension(collector, host)],
startup_snapshot: Some(startup_snapshot()),
create_params: Some(create_params),
..Default::default()
});
phases.isolate = phase_started.elapsed();
let invocation_deadline = std::time::Instant::now() + max_duration;
let isolate_handle = js_runtime.v8_isolate().thread_safe_handle();
let watchdog_done = Arc::new(watchdog::WatchdogSignal::new());
let watchdog_done_thread = Arc::clone(&watchdog_done);
let timed_out_watchdog = Arc::clone(&timed_out_run);
let watchdog = std::thread::spawn(move || {
if watchdog_done_thread.wait_until(invocation_deadline)
== watchdog::WatchdogOutcome::DeadlineReached
{
timed_out_watchdog.store(true, Ordering::Release);
isolate_handle.terminate_execution();
}
});
let mem_flag = Arc::clone(&mem_exceeded_run);
let heap_handle = js_runtime.v8_isolate().thread_safe_handle();
js_runtime.add_near_heap_limit_callback(move |current_limit, _initial| {
mem_flag.store(true, Ordering::Release);
heap_handle.terminate_execution();
current_limit.saturating_add(current_limit / 2).max(current_limit + 1)
});
let classify = |raw: &str, mem: &Arc<AtomicBool>, time: &Arc<AtomicBool>| -> String {
if mem.load(Ordering::Acquire) {
"Memory limit exceeded: heap allocation exceeded the configured limit".to_string()
} else if time.load(Ordering::Acquire) {
"Execution timeout: script exceeded the configured time limit".to_string()
} else if raw.contains("SyntaxError") || raw.contains("Parse") {
format!("SyntaxError: {raw}")
} else {
format!("Execution error: {raw}")
}
};
let phase_started = Instant::now();
let exec_outcome = js_runtime.execute_script("<fraiseql-function>", wrapped);
phases.script = phase_started.elapsed();
if let Err(e) = exec_outcome {
watchdog_done.finish();
let _ = watchdog.join();
return Err(classify(&e.to_string(), &mem_exceeded_run, &timed_out_run));
}
let phase_started = Instant::now();
let loop_outcome = tokio::time::timeout(
invocation_deadline.saturating_duration_since(std::time::Instant::now()),
js_runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
)
.await;
phases.event_loop = phase_started.elapsed();
watchdog_done.finish();
let _ = watchdog.join();
match loop_outcome {
Err(_) => {
return Err("Execution timeout: event loop exceeded time limit".to_string());
},
Ok(Err(e)) => {
return Err(classify(&e.to_string(), &mem_exceeded_run, &timed_out_run));
},
Ok(Ok(())) => {},
}
let phase_started = Instant::now();
let result_global = js_runtime
.execute_script("<get-result>", "globalThis.__fraiseql_result")
.map_err(|e| format!("Failed to read result: {e}"))?;
let error_global = js_runtime
.execute_script("<get-error>", "globalThis.__fraiseql_error")
.map_err(|e| format!("Failed to read error: {e}"))?;
let (result_json, error_str) = {
let scope = &mut js_runtime.handle_scope();
let result_local = deno_core::v8::Local::new(scope, result_global);
let error_local = deno_core::v8::Local::new(scope, error_global);
if result_local.is_undefined() && error_local.is_undefined() {
return Err("Execution incomplete: function did not produce a result \
(possible unresolved promise)"
.to_string());
}
let error_str = if error_local.is_null_or_undefined() {
None
} else {
Some(error_local.to_rust_string_lossy(scope))
};
let result_json = if result_local.is_null_or_undefined() {
None
} else {
Some(result_local.to_rust_string_lossy(scope))
};
(result_json, error_str)
};
if let Some(err) = error_str {
return Err(format!("Runtime error: {err}"));
}
let value: Value = match result_json {
Some(json_str) => serde_json::from_str(&json_str).unwrap_or(Value::String(json_str)),
None => Value::Null,
};
phases.result = phase_started.elapsed();
let phase_started = Instant::now();
drop(js_runtime);
phases.teardown = phase_started.elapsed();
Ok(value)
});
let phase_started = Instant::now();
drop(rt);
phases.teardown += phase_started.elapsed();
let logs = logs_arc.lock().expect("log mutex poisoned").clone();
match result {
Ok(value) => Ok(ExecutionResult {
value,
logs,
phases,
}),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests;