Skip to main content

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}