fraiseql_functions/runtime/deno/executor.rs
1//! Deno executor implementation — handles V8 isolation and module execution.
2//!
3//! # Architecture
4//!
5//! Each invocation spawns a fresh OS thread with a dedicated single-threaded Tokio
6//! runtime. This sidesteps the "cannot call `block_on` inside a Tokio runtime"
7//! restriction while keeping V8 + its event loop on an uncontested thread.
8//!
9//! The result is returned to the outer async context via a `oneshot` channel.
10
11use std::{
12 cell::RefCell,
13 rc::Rc,
14 sync::{
15 Arc, Mutex,
16 atomic::{AtomicBool, Ordering},
17 },
18};
19
20use deno_core::{Extension, JsRuntime, OpState, RuntimeOptions, op2, v8};
21use serde_json::Value;
22
23use super::{ops::DenoHostContext, watchdog};
24use crate::{
25 host::dyn_context::DynHostContext,
26 types::{LogEntry, LogLevel, ResourceLimits},
27};
28
29// ── V8 platform initialization ─────────────────────────────────────────────────
30
31/// Initialize the process-wide V8 platform as the **unprotected** default
32/// platform, once, before any isolate exists.
33///
34/// Without this, the first `JsRuntime::new` lazily initializes `deno_core`'s
35/// *protected* default platform, whose JIT pages are hardened with memory
36/// protection keys (PKU). Pkey access rights are per-thread and inherited only
37/// by threads **descended from the initializing thread** — and every FraiseQL
38/// invocation runs on its own short-lived thread, so the lazily-bound platform
39/// died with invocation 1's thread and the second Deno invocation in any
40/// process faulted on the first JIT page it touched (SIGSEGV, #969). A library
41/// cannot guarantee that all future invocation threads descend from one common
42/// initializer, which is exactly the case `rusty_v8` documents the unprotected
43/// platform for. The cost is V8's pkey JIT-page hardening (defense-in-depth
44/// against V8-level exploits); ordinary W^X page protection still applies.
45fn ensure_v8_platform() {
46 static V8_PLATFORM: std::sync::Once = std::sync::Once::new();
47 V8_PLATFORM.call_once(|| {
48 JsRuntime::init_platform(Some(
49 v8::new_unprotected_default_platform(0, false).make_shared(),
50 ));
51 });
52}
53
54// ── Log collector state stored in OpState ─────────────────────────────────────
55
56/// Shared log collector threaded into the `fraiseql_log` op via `OpState`.
57#[derive(Clone)]
58struct LogCollector {
59 logs: Arc<Mutex<Vec<LogEntry>>>,
60 max_entries: usize,
61}
62
63// ── Op definitions ─────────────────────────────────────────────────────────────
64
65/// Log a message from Deno guest code.
66///
67/// Called by guests as `Deno.core.ops.fraiseql_log(level, message)`.
68/// Levels: 0 = debug, 1 = info, 2 = warn, 3 = error.
69#[op2(fast)]
70#[allow(clippy::inline_always)] // Reason: emitted by the `#[op2]` proc-macro, not our code
71#[allow(clippy::needless_pass_by_value)] // Reason: `#[op2]` requires owned `String` for `#[string]`
72fn fraiseql_log(state: Rc<RefCell<OpState>>, #[smi] level: u8, #[string] message: String) {
73 let state = state.borrow();
74 let collector = state.borrow::<LogCollector>();
75 let mut logs = collector.logs.lock().expect("log mutex poisoned");
76 if logs.len() < collector.max_entries {
77 let log_level = match level {
78 0 => LogLevel::Debug,
79 2 => LogLevel::Warn,
80 3 => LogLevel::Error,
81 _ => LogLevel::Info,
82 };
83 logs.push(LogEntry {
84 level: log_level,
85 message,
86 timestamp: chrono::Utc::now(),
87 });
88 }
89}
90
91// ── Extension builder ──────────────────────────────────────────────────────────
92
93fn make_fraiseql_extension(
94 collector: LogCollector,
95 host: Option<Arc<dyn DynHostContext>>,
96) -> Extension {
97 use super::ops;
98 Extension {
99 name: "fraiseql",
100 ops: std::borrow::Cow::Owned(vec![
101 fraiseql_log(),
102 ops::fraiseql_query(),
103 ops::fraiseql_sql_query(),
104 ops::fraiseql_http_request(),
105 ops::fraiseql_storage_get(),
106 ops::fraiseql_storage_put(),
107 ops::fraiseql_send_email(),
108 ops::fraiseql_auth_context(),
109 ops::fraiseql_env_var(),
110 ops::fraiseql_idempotency_token(),
111 ops::fraiseql_cursor_get(),
112 ops::fraiseql_cursor_advance(),
113 ]),
114 op_state_fn: Some(Box::new(move |state: &mut OpState| {
115 state.put(collector);
116 if let Some(host) = host {
117 state.put(DenoHostContext(host));
118 }
119 })),
120 ..Default::default()
121 }
122}
123
124// ── Source preprocessing ────────────────────────────────────────────────────────
125
126/// Wrap the user-provided source so the default-exported function is called with
127/// the event data and the result stored in `globalThis.__fraiseql_result`.
128///
129/// Handles both:
130/// `export default async (event) => { ... };`
131/// `export default async function(event) { ... }`
132///
133/// The wrapped code also catches any thrown exception and stores it in
134/// `globalThis.__fraiseql_error`.
135fn wrap_source(source: &str, event_json: &str) -> String {
136 // Convert "export default <expr>" to "const __fn = <expr>"
137 let inner = source
138 .replace("export default async function", "const __fn = async function")
139 .replace("export default async", "const __fn = async")
140 .replace("export default function", "const __fn = function")
141 .replace("export default", "const __fn =");
142
143 // Injected so a guest can mark a thrown error permanent structurally
144 // (`throw Object.assign(new Error(msg), {{ fraiseqlPermanent: true }})`) and the
145 // runtime dead-letters it immediately. Host-op errors already carry the marker
146 // in their message; this folds the property form into the same signal.
147 let marker = crate::types::PERMANENT_ERROR_MARKER;
148 format!(
149 r#"
150{inner}
151(async () => {{
152 try {{
153 const __event = {event_json};
154 const __result = await __fn(__event);
155 globalThis.__fraiseql_result = JSON.stringify(__result);
156 globalThis.__fraiseql_error = null;
157 }} catch (e) {{
158 globalThis.__fraiseql_result = null;
159 const __permanent = e && e.fraiseqlPermanent === true;
160 globalThis.__fraiseql_error = (__permanent ? "{marker} " : "") + String(e);
161 }}
162}})();
163"#
164 )
165}
166
167// ── Execution result ────────────────────────────────────────────────────────────
168
169/// Result returned from the blocking deno thread back to the async caller.
170pub struct ExecutionResult {
171 /// The value returned by the function (serialised → deserialised).
172 pub value: Value,
173 /// Log entries captured during execution.
174 pub logs: Vec<LogEntry>,
175}
176
177// ── Core execution (runs on a dedicated thread with its own Tokio runtime) ─────
178
179/// Execute guest `JavaScript` inside a fresh `JsRuntime` + dedicated Tokio runtime.
180///
181/// This function is called from inside `std::thread::spawn`, so it is free to
182/// create its own `current_thread` Tokio runtime and call `block_on` without
183/// conflict.
184///
185/// # Errors
186///
187/// Returns an error string on syntax errors, runtime exceptions, timeout, or
188/// resource-limit violations.
189///
190/// # Panics
191///
192/// Panics if the internal log mutex is poisoned (only possible if another thread
193/// panicked while holding the lock, which cannot happen in normal operation).
194pub fn run_in_dedicated_thread(
195 source: &str,
196 event_value: &Value,
197 limits: &ResourceLimits,
198 host: Option<Arc<dyn DynHostContext>>,
199) -> Result<ExecutionResult, String> {
200 // Resource limits are enforced for real against the V8 isolate (M-deno-limits):
201 //
202 // * Memory: the isolate is created with a hard heap limit (`max_memory_bytes`) and a
203 // near-heap-limit callback that terminates execution when V8 approaches that limit.
204 // * CPU/time: a watchdog thread terminates the isolate after `max_duration`, catching tight
205 // synchronous loops (`while (true) {}`) that never yield to the async event loop, in
206 // addition to the event-loop `tokio::time::timeout` guard that catches async hangs
207 // (unresolved promises).
208 //
209 // The previous substring heuristics (matching `while (true)` / `ArrayBuffer` in
210 // the source) were removed: they false-positived on those substrings appearing
211 // in comments or string literals and missed every other form of DoS.
212
213 // The platform must exist (and must be the unprotected one, #969) before
214 // the first isolate in the process is created below.
215 ensure_v8_platform();
216
217 // Shared log storage
218 let logs_arc: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
219 let collector = LogCollector {
220 logs: Arc::clone(&logs_arc),
221 max_entries: limits.max_log_entries,
222 };
223
224 // Serialise the event data for injection into JS
225 let event_json = serde_json::to_string(event_value).map_err(|e| e.to_string())?;
226
227 // Build the wrapper script
228 let wrapped = wrap_source(source, &event_json);
229
230 // Extract before async move to avoid partial-move into the closure.
231 let max_duration = limits.max_duration;
232 // V8's heap limit is expressed in `usize`; saturate on 32-bit targets where the
233 // configured `u64` limit could exceed the address space.
234 let max_memory_bytes = usize::try_from(limits.max_memory_bytes).unwrap_or(usize::MAX);
235
236 // Flags shared with the heap-limit callback and the watchdog thread so that,
237 // once V8 execution is terminated, we can report *why* (memory vs. timeout)
238 // rather than surfacing the opaque "execution terminated" V8 error.
239 let mem_exceeded_run = Arc::new(AtomicBool::new(false));
240 let timed_out_run = Arc::new(AtomicBool::new(false));
241
242 // Create a single-threaded Tokio runtime for deno's event loop.
243 // This is safe because we're in a fresh OS thread with no existing Tokio context.
244 let rt = tokio::runtime::Builder::new_current_thread()
245 .enable_all()
246 .build()
247 .map_err(|e| format!("Failed to create tokio runtime: {e}"))?;
248
249 let result = rt.block_on(async move {
250 // Hard heap limit enforced by V8: the isolate may not grow past
251 // `max_memory_bytes`. `0` initial lets V8 pick a sane starting size.
252 let create_params = v8::CreateParams::default().heap_limits(0, max_memory_bytes);
253
254 let mut js_runtime = JsRuntime::new(RuntimeOptions {
255 extensions: vec![make_fraiseql_extension(collector, host)],
256 create_params: Some(create_params),
257 ..Default::default()
258 });
259
260 // Watchdog: terminate the isolate after `max_duration`. This catches tight
261 // *synchronous* loops (`while (true) {}`) that never yield to the async
262 // event loop, so the `tokio::time::timeout` below cannot fire. The handle
263 // is thread-safe (Send + Sync) and may be invoked from any thread.
264 //
265 // ONE deadline covers the whole invocation — script evaluation AND the
266 // event loop. The watchdog must stay armed across `run_event_loop`
267 // (#804): a guest that `await`s a real host op resumes *inside* the
268 // event loop, and a synchronous spin there never yields to tokio, so
269 // the `tokio::time::timeout` future is never polled and only this
270 // thread can terminate the isolate.
271 let invocation_deadline = std::time::Instant::now() + max_duration;
272 let isolate_handle = js_runtime.v8_isolate().thread_safe_handle();
273 // Signalled, not polled (#1342). The watchdog blocks until the deadline or
274 // until the invocation says it is done — whichever comes first — so the
275 // `join` below costs nothing. It used to poll a flag on a 10 ms sleep, and
276 // the join waited out the rest of that sleep on *every* invocation: ~9 ms,
277 // about 40 % of the total, against a guest whose own work was under 1 ms.
278 let watchdog_done = Arc::new(watchdog::WatchdogSignal::new());
279 let watchdog_done_thread = Arc::clone(&watchdog_done);
280 let timed_out_watchdog = Arc::clone(&timed_out_run);
281 let watchdog = std::thread::spawn(move || {
282 if watchdog_done_thread.wait_until(invocation_deadline)
283 == watchdog::WatchdogOutcome::DeadlineReached
284 {
285 timed_out_watchdog.store(true, Ordering::Release);
286 isolate_handle.terminate_execution();
287 }
288 });
289
290 // Near-heap-limit callback: when V8 approaches the hard limit, flag the
291 // condition and terminate execution. We bump the returned limit so V8 does
292 // not `FatalProcessOutOfMemory` (which would abort the whole process)
293 // before the termination exception propagates out of the running script.
294 let mem_flag = Arc::clone(&mem_exceeded_run);
295 let heap_handle = js_runtime.v8_isolate().thread_safe_handle();
296 js_runtime.add_near_heap_limit_callback(move |current_limit, _initial| {
297 mem_flag.store(true, Ordering::Release);
298 heap_handle.terminate_execution();
299 // Grant headroom so V8 can unwind via the termination exception instead
300 // of crashing the process.
301 current_limit.saturating_add(current_limit / 2).max(current_limit + 1)
302 });
303
304 // Helper to convert a raw V8/deno error into a meaningful message,
305 // distinguishing limit-driven termination from ordinary failures.
306 let classify = |raw: &str, mem: &Arc<AtomicBool>, time: &Arc<AtomicBool>| -> String {
307 if mem.load(Ordering::Acquire) {
308 "Memory limit exceeded: heap allocation exceeded the configured limit".to_string()
309 } else if time.load(Ordering::Acquire) {
310 "Execution timeout: script exceeded the configured time limit".to_string()
311 } else if raw.contains("SyntaxError") || raw.contains("Parse") {
312 format!("SyntaxError: {raw}")
313 } else {
314 format!("Execution error: {raw}")
315 }
316 };
317
318 // Execute the wrapped script. The watchdog stays ARMED past this point
319 // (#804): the guest's continuations after `await` run inside
320 // `run_event_loop` below, and a synchronous spin there is only
321 // stoppable by the watchdog's `terminate_execution`.
322 let exec_outcome = js_runtime.execute_script("<fraiseql-function>", wrapped);
323
324 if let Err(e) = exec_outcome {
325 // Stop and reap the watchdog before returning.
326 watchdog_done.finish();
327 let _ = watchdog.join();
328 return Err(classify(&e.to_string(), &mem_exceeded_run, &timed_out_run));
329 }
330
331 // Drive the event loop to resolve Promises from async functions. The
332 // tokio timeout covers *async idle* hangs (a pending promise that never
333 // resolves — no JS running, so `terminate_execution` has nothing to
334 // terminate); the still-armed watchdog covers *synchronous* spins that
335 // never yield back to tokio. Both share the single invocation budget.
336 let loop_outcome = tokio::time::timeout(
337 invocation_deadline.saturating_duration_since(std::time::Instant::now()),
338 js_runtime.run_event_loop(deno_core::PollEventLoopOptions::default()),
339 )
340 .await;
341
342 // The event loop is done (or timed out): stop and reap the watchdog.
343 watchdog_done.finish();
344 let _ = watchdog.join();
345
346 match loop_outcome {
347 Err(_) => {
348 return Err("Execution timeout: event loop exceeded time limit".to_string());
349 },
350 Ok(Err(e)) => {
351 return Err(classify(&e.to_string(), &mem_exceeded_run, &timed_out_run));
352 },
353 Ok(Ok(())) => {},
354 }
355
356 // Retrieve the result stored in globalThis.__fraiseql_result
357 let result_global = js_runtime
358 .execute_script("<get-result>", "globalThis.__fraiseql_result")
359 .map_err(|e| format!("Failed to read result: {e}"))?;
360
361 let error_global = js_runtime
362 .execute_script("<get-error>", "globalThis.__fraiseql_error")
363 .map_err(|e| format!("Failed to read error: {e}"))?;
364
365 // Inspect the values via a V8 handle scope
366 let (result_json, error_str) = {
367 let scope = &mut js_runtime.handle_scope();
368
369 let result_local = deno_core::v8::Local::new(scope, result_global);
370 let error_local = deno_core::v8::Local::new(scope, error_global);
371
372 // Both undefined means the IIFE wrapper never completed (e.g. an unresolvable
373 // Promise caused the event loop to drain without the try/catch finalising).
374 if result_local.is_undefined() && error_local.is_undefined() {
375 return Err("Execution incomplete: function did not produce a result \
376 (possible unresolved promise)"
377 .to_string());
378 }
379
380 let error_str = if error_local.is_null_or_undefined() {
381 None
382 } else {
383 Some(error_local.to_rust_string_lossy(scope))
384 };
385
386 let result_json = if result_local.is_null_or_undefined() {
387 None
388 } else {
389 Some(result_local.to_rust_string_lossy(scope))
390 };
391
392 (result_json, error_str)
393 };
394
395 // If the guest threw an error, propagate it
396 if let Some(err) = error_str {
397 return Err(format!("Runtime error: {err}"));
398 }
399
400 // Parse the JSON result
401 let value: Value = match result_json {
402 Some(json_str) => serde_json::from_str(&json_str).unwrap_or(Value::String(json_str)),
403 None => Value::Null,
404 };
405
406 Ok(value)
407 });
408
409 // Collect logs
410 let logs = logs_arc.lock().expect("log mutex poisoned").clone();
411
412 match result {
413 Ok(value) => Ok(ExecutionResult { value, logs }),
414 Err(e) => Err(e),
415 }
416}