aardvark_core/
engine.rs

1//! Lightweight V8 runtime utilities.
2
3use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5use std::convert::TryFrom;
6use std::env;
7use std::ffi::OsStr;
8use std::fs;
9use std::io::Read;
10use std::path::{Component, Path, PathBuf};
11use std::rc::Rc;
12use std::sync::Arc;
13
14use crate::asset_store::{Asset, AssetStore};
15use crate::bundle::Bundle;
16use crate::error::{PyRunnerError, Result};
17use bytes::Bytes;
18use once_cell::sync::{Lazy, OnceCell};
19use parking_lot::RwLock;
20use serde::Deserialize;
21use serde_json::Value as JsonValue;
22use tracing::{debug, info, warn};
23use url::Url;
24use v8::{
25    self, script_compiler, Array, Context, ContextScope, FixedArray, Function,
26    FunctionCallbackArguments, Local, Module, ModuleRequest, Object, PinScope, Promise,
27    PromiseState, ReturnValue, String as V8String, Uint8Array, Value,
28};
29use walkdir::WalkDir;
30static V8_PLATFORM: OnceCell<v8::SharedRef<v8::Platform>> = OnceCell::new();
31static PACKAGE_ROOT: Lazy<RwLock<Option<PathBuf>>> = Lazy::new(|| RwLock::new(None));
32
33#[derive(Debug, Clone)]
34struct HostPattern {
35    kind: HostPatternKind,
36    port: Option<u16>,
37}
38
39#[derive(Debug, Clone)]
40enum HostPatternKind {
41    Exact(String),
42    WildcardSuffix(String),
43}
44
45#[derive(Debug, Clone)]
46pub struct NetworkContactRecord {
47    pub host: String,
48    pub port: Option<u16>,
49    pub https: bool,
50}
51
52#[derive(Debug, Clone)]
53pub struct NetworkDeniedRecord {
54    pub host: String,
55    pub port: Option<u16>,
56    pub reason: String,
57    pub https_required: bool,
58}
59
60#[derive(Debug, Clone)]
61struct NetworkPolicy {
62    entries: Vec<HostPattern>,
63    https_only: bool,
64}
65
66#[derive(Debug, Clone)]
67pub struct FilesystemViolationRecord {
68    pub path: Option<String>,
69    pub message: String,
70}
71
72impl Default for NetworkPolicy {
73    fn default() -> Self {
74        Self {
75            entries: Vec::new(),
76            https_only: true,
77        }
78    }
79}
80
81impl NetworkPolicy {
82    fn new(allow: &[String], https_only: bool) -> Self {
83        let entries = allow
84            .iter()
85            .filter_map(|value| HostPattern::from_pattern(value))
86            .collect();
87        Self {
88            entries,
89            https_only,
90        }
91    }
92
93    fn evaluate(&self, host: &str, port: Option<u16>, is_https: bool) -> NetworkDecision {
94        if self.entries.is_empty() {
95            return NetworkDecision::Denied(NetworkDenyReason::NoAllowlist);
96        }
97        if self.https_only && !is_https {
98            return NetworkDecision::Denied(NetworkDenyReason::SchemeNotAllowed);
99        }
100        let host_lc = host.to_ascii_lowercase();
101        for pattern in &self.entries {
102            if pattern.matches(&host_lc, port) {
103                return NetworkDecision::Allowed;
104            }
105        }
106        NetworkDecision::Denied(NetworkDenyReason::HostNotAllowed)
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum NetworkDecision {
112    Allowed,
113    Denied(NetworkDenyReason),
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum NetworkDenyReason {
118    NoAllowlist,
119    SchemeNotAllowed,
120    HostNotAllowed,
121}
122
123impl NetworkDenyReason {
124    fn as_str(&self) -> &'static str {
125        match self {
126            NetworkDenyReason::NoAllowlist => "no-allowlist",
127            NetworkDenyReason::SchemeNotAllowed => "scheme-not-allowed",
128            NetworkDenyReason::HostNotAllowed => "host-not-allowed",
129        }
130    }
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub enum FilesystemModeConfig {
135    Read,
136    ReadWrite,
137}
138
139impl HostPattern {
140    fn from_pattern(value: &str) -> Option<Self> {
141        let trimmed = value.trim();
142        if trimmed.is_empty() {
143            return None;
144        }
145        let lowered = trimmed.to_ascii_lowercase();
146        let (host_part, port) = split_host_and_port(&lowered);
147        if host_part.is_empty() {
148            return None;
149        }
150        if host_part.starts_with("*.") {
151            let suffix = host_part.trim_start_matches("*.").to_owned();
152            if suffix.is_empty() {
153                return None;
154            }
155            Some(Self {
156                kind: HostPatternKind::WildcardSuffix(suffix),
157                port,
158            })
159        } else {
160            Some(Self {
161                kind: HostPatternKind::Exact(host_part),
162                port,
163            })
164        }
165    }
166
167    fn matches(&self, host: &str, port: Option<u16>) -> bool {
168        let port_allowed = match (self.port, port) {
169            (Some(expected), Some(actual)) => expected == actual,
170            (Some(_), None) => false,
171            _ => true,
172        };
173        if !port_allowed {
174            return false;
175        }
176        match &self.kind {
177            HostPatternKind::Exact(expected) => host == expected,
178            HostPatternKind::WildcardSuffix(suffix) => host.ends_with(suffix),
179        }
180    }
181}
182
183fn split_host_and_port(value: &str) -> (String, Option<u16>) {
184    if let Some(idx) = value.rfind(':') {
185        let (host_part, port_part) = value.split_at(idx);
186        let port_str = &port_part[1..];
187        if !port_str.is_empty() && port_str.chars().all(|c| c.is_ascii_digit()) {
188            if let Ok(port) = port_str.parse::<u16>() {
189                return (host_part.to_owned(), Some(port));
190            }
191        }
192    }
193    (value.to_owned(), None)
194}
195
196/// Options that influence how Pyodide is initialized inside the JS runtime.
197pub struct PyodideLoadOptions<'a> {
198    pub snapshot: Option<&'a [u8]>,
199    pub make_snapshot: bool,
200}
201
202/// Overlay blob entry associated with a snapshot export/import.
203pub struct OverlayBlob {
204    pub key: String,
205    pub digest: Option<String>,
206    pub bytes: Vec<u8>,
207}
208
209/// Overlay export bundle containing metadata JSON and associated tar blobs.
210pub struct OverlayExport {
211    pub metadata: Vec<u8>,
212    pub blobs: Vec<OverlayBlob>,
213}
214
215fn package_root_dir() -> Option<PathBuf> {
216    if let Some(path) = PACKAGE_ROOT.read().as_ref() {
217        return Some(path.clone());
218    }
219    let resolved = env::var_os("AARDVARK_PYODIDE_PACKAGE_DIR")
220        .map(PathBuf::from)
221        .map(normalize_package_root);
222    if let Some(ref path) = resolved {
223        tracing::debug!(
224            target = "aardvark::packages",
225            path = %path.display(),
226            "initialised package root"
227        );
228        *PACKAGE_ROOT.write() = Some(path.clone());
229    }
230    resolved
231}
232
233pub(crate) fn set_package_root_override(path: Option<PathBuf>) {
234    let normalized = path.map(normalize_package_root);
235    {
236        let mut guard = PACKAGE_ROOT.write();
237        *guard = normalized.clone();
238    }
239    match normalized {
240        Some(ref path) => tracing::debug!(
241            target = "aardvark::packages",
242            path = %path.display(),
243            "package root override set"
244        ),
245        None => tracing::debug!(
246            target = "aardvark::packages",
247            "cleared package root override"
248        ),
249    }
250}
251
252fn normalize_package_root(path: PathBuf) -> PathBuf {
253    if path.is_relative() {
254        if let Ok(cwd) = env::current_dir() {
255            return cwd.join(path);
256        }
257    }
258    path
259}
260
261#[cfg(test)]
262pub(crate) fn reset_package_root_for_tests() {
263    *PACKAGE_ROOT.write() = None;
264}
265
266fn resolve_local_package_path(url: &str) -> Option<PathBuf> {
267    let root = package_root_dir()?;
268    let scheme_split = url.find("://").map(|idx| idx + 3).unwrap_or(0);
269    let remainder = &url[scheme_split..];
270    let path_part = remainder.split_once('/').map_or("", |(_, rest)| rest);
271    if path_part.is_empty() {
272        return None;
273    }
274    let trimmed = path_part
275        .split(['?', '#'])
276        .next()
277        .unwrap_or("")
278        .trim_start_matches('/');
279    if trimmed.is_empty() {
280        return None;
281    }
282    let as_path = Path::new(trimmed);
283    let mut attempts: Vec<PathBuf> = Vec::new();
284
285    if let Some(file_name) = as_path.file_name() {
286        push_unique(&mut attempts, root.join(file_name));
287    }
288
289    if let Some(variant_relative) = strip_variant_prefix(as_path) {
290        push_unique(&mut attempts, root.join(&variant_relative));
291        if let Some(last) = variant_relative.file_name() {
292            push_unique(&mut attempts, root.join(last));
293        }
294    }
295
296    push_unique(&mut attempts, root.join(as_path));
297    push_unique(&mut attempts, root.join("pyodide").join(as_path));
298
299    if let Some(file_name) = as_path.file_name() {
300        push_unique(&mut attempts, root.join("full").join(file_name));
301    }
302
303    for candidate in attempts {
304        tracing::debug!(
305            target = "aardvark::packages",
306            path = %candidate.display(),
307            exists = candidate.exists(),
308            "checking local package candidate"
309        );
310        if candidate.exists() {
311            tracing::debug!(
312                target = "aardvark::packages",
313                path = %candidate.display(),
314                "resolved local package path"
315            );
316            return Some(candidate);
317        }
318    }
319    if let Some(file_name) = as_path.file_name() {
320        if let Some(found) = walk_for_file(&root, file_name) {
321            tracing::debug!(
322                target = "aardvark::packages",
323                path = %found.display(),
324                "resolved local package path via search"
325            );
326            return Some(found);
327        }
328    }
329    None
330}
331
332fn strip_variant_prefix(path: &Path) -> Option<PathBuf> {
333    let mut components = path.components();
334    match (components.next()?, components.next(), components.next()) {
335        (
336            Component::Normal(first),
337            Some(Component::Normal(_version)),
338            Some(Component::Normal(_variant)),
339        ) if first == OsStr::new("pyodide") => {
340            let remaining = components.as_path();
341            if remaining.as_os_str().is_empty() {
342                None
343            } else {
344                Some(remaining.to_path_buf())
345            }
346        }
347        _ => None,
348    }
349}
350
351fn push_unique(list: &mut Vec<PathBuf>, candidate: PathBuf) {
352    if !list.iter().any(|existing| existing == &candidate) {
353        list.push(candidate);
354    }
355}
356
357fn walk_for_file(root: &Path, needle: &OsStr) -> Option<PathBuf> {
358    let walker = WalkDir::new(root).into_iter();
359    for entry in walker.filter_map(|e| e.ok()) {
360        let path = entry.path();
361        if path.file_name() == Some(needle) {
362            return Some(path.to_path_buf());
363        }
364    }
365    None
366}
367
368fn guess_content_type(path: &Path) -> &'static str {
369    match path
370        .extension()
371        .and_then(|ext| ext.to_str())
372        .map(|s| s.to_ascii_lowercase())
373        .as_deref()
374    {
375        Some("json") => "application/json",
376        Some("js") => "application/javascript",
377        Some("wasm") => "application/wasm",
378        Some("txt") => "text/plain; charset=utf-8",
379        Some("py") => "text/x-python",
380        Some("data") => "application/octet-stream",
381        Some("whl") => "application/octet-stream",
382        Some("zip") => "application/zip",
383        Some("gz") => "application/gzip",
384        Some("bz2") => "application/x-bzip2",
385        Some("tar") => "application/x-tar",
386        _ => "application/octet-stream",
387    }
388}
389
390fn copy_typed_array(array: Local<Uint8Array>) -> Vec<u8> {
391    let length = array.length();
392    let mut data = vec![0u8; length];
393    array.copy_contents(&mut data);
394    data
395}
396
397thread_local! {
398    static TLS_RUNTIME_CONTEXT: Cell<*const RuntimeContext> = const { Cell::new(std::ptr::null()) };
399    static TLS_SCOPE: Cell<*mut std::ffi::c_void> = const { Cell::new(std::ptr::null_mut()) };
400}
401
402/// Ensure the global V8 platform is initialized exactly once.
403fn init_v8() {
404    V8_PLATFORM.get_or_init(|| {
405        let platform = v8::new_default_platform(0, false);
406        let shared = platform.make_shared();
407        v8::V8::initialize_platform(shared.clone());
408        v8::V8::initialize();
409        shared
410    });
411}
412
413/// A thin wrapper around an owned V8 isolate and context.
414pub struct JsRuntime {
415    isolate: v8::OwnedIsolate,
416    context: v8::Global<v8::Context>,
417    context_state: Rc<RuntimeContext>,
418}
419
420struct RuntimeContext {
421    assets: AssetStore,
422    modules: RefCell<HashMap<String, v8::Global<Module>>>,
423    module_by_hash: RefCell<HashMap<i32, String>>,
424    module_namespaces: RefCell<HashMap<String, v8::Global<Object>>>,
425    pyodide_instance: RefCell<Option<v8::Global<Object>>>,
426    stdout_log: RefCell<String>,
427    stderr_log: RefCell<String>,
428    network_policy: RwLock<NetworkPolicy>,
429    network_contacts: RwLock<Vec<NetworkContactRecord>>,
430    network_denied: RwLock<Vec<NetworkDeniedRecord>>,
431    filesystem_violations: RwLock<Vec<FilesystemViolationRecord>>,
432}
433
434enum ConsoleStream {
435    Stdout,
436    Stderr,
437}
438
439impl JsRuntime {
440    /// Creates a new isolate with an empty context and basic polyfills.
441    pub fn new() -> Result<Self> {
442        init_v8();
443        let context_state = Rc::new(RuntimeContext::new());
444        let create_params =
445            v8::CreateParams::default().array_buffer_allocator(v8::new_default_allocator());
446        let mut isolate = v8::Isolate::new(create_params);
447        isolate.set_slot(context_state.clone());
448        let global = {
449            v8::scope!(let scope, &mut isolate);
450            let context = v8::Context::new(scope, v8::ContextOptions::default());
451            v8::Global::new(scope, context)
452        };
453
454        let mut runtime = Self {
455            isolate,
456            context: global,
457            context_state,
458        };
459        runtime.install_polyfills()?;
460        Ok(runtime)
461    }
462
463    /// Reinitializes the isolate in place, keeping the outer runtime alive.
464    pub fn reset(&mut self) -> Result<()> {
465        // Drop the previous context state so any module caches or globals are released.
466        let new_state = Rc::new(RuntimeContext::new());
467        self.context_state = new_state.clone();
468        self.isolate.set_slot(new_state);
469
470        // Hint V8 to reclaim memory from the old context before installing a new one.
471        self.isolate.low_memory_notification();
472
473        let global = {
474            v8::scope!(let scope, &mut self.isolate);
475            let context = v8::Context::new(scope, v8::ContextOptions::default());
476            v8::Global::new(scope, context)
477        };
478        self.context = global;
479        self.install_polyfills()
480    }
481
482    /// Configures the network allowlist for subsequent native fetches.
483    pub fn set_network_policy(&self, allow: &[String], https_only: bool) {
484        self.context_state.set_network_policy(allow, https_only);
485    }
486
487    /// Clears any recorded network contacts before a new invocation begins.
488    pub fn clear_network_contacts(&self) {
489        self.context_state.clear_network_contacts();
490    }
491
492    /// Consumes and returns the recorded network contacts from the last invocation.
493    pub fn drain_network_contacts(&self) -> Vec<NetworkContactRecord> {
494        self.context_state.take_network_contacts()
495    }
496
497    /// Clears any recorded denied network attempts before a new invocation begins.
498    pub fn clear_network_denied(&self) {
499        self.context_state.clear_network_denied();
500    }
501
502    /// Consumes and returns network attempts that were blocked by policy.
503    pub fn drain_network_denied(&self) -> Vec<NetworkDeniedRecord> {
504        self.context_state.take_network_denied()
505    }
506
507    /// Clears filesystem violation events.
508    pub fn clear_filesystem_events(&self) {
509        self.context_state.clear_filesystem_violations();
510    }
511
512    /// Consumes filesystem violations captured during the invocation.
513    pub fn drain_filesystem_violations(&self) -> Vec<FilesystemViolationRecord> {
514        self.context_state.take_filesystem_violations()
515    }
516
517    /// Applies filesystem mode and quota before executing user code.
518    pub fn set_filesystem_policy(
519        &mut self,
520        mode: FilesystemModeConfig,
521        quota_bytes: Option<u64>,
522    ) -> Result<()> {
523        self.with_context(|scope, _| {
524            let global = scope.get_current_context().global(scope);
525            let key = v8::String::new(scope, "__aardvarkFilesystemSetPolicy").ok_or_else(|| {
526                PyRunnerError::Execution("filesystem policy hook unavailable".into())
527            })?;
528            let value = global.get(scope, key.into()).ok_or_else(|| {
529                PyRunnerError::Execution("__aardvarkFilesystemSetPolicy missing".into())
530            })?;
531            let func = Local::<Function>::try_from(value).map_err(|_| {
532                PyRunnerError::Execution("__aardvarkFilesystemSetPolicy is not a function".into())
533            })?;
534            let policy = v8::Object::new(scope);
535            let mode_key = v8::String::new(scope, "mode").unwrap();
536            let mode_value = v8::String::new(
537                scope,
538                match mode {
539                    FilesystemModeConfig::Read => "read",
540                    FilesystemModeConfig::ReadWrite => "readWrite",
541                },
542            )
543            .unwrap();
544            let _ = policy.set(scope, mode_key.into(), mode_value.into());
545            if let Some(quota) = quota_bytes {
546                let quota_key = v8::String::new(scope, "quotaBytes").unwrap();
547                let quota_value = v8::Number::new(scope, quota as f64);
548                let _ = policy.set(scope, quota_key.into(), quota_value.into());
549            }
550            func.call(scope, global.into(), &[policy.into()])
551                .ok_or_else(|| {
552                    PyRunnerError::Execution("filesystem policy update failed".into())
553                })?;
554            Ok(())
555        })
556    }
557
558    /// Executes a JavaScript module export and returns the normalized output.
559    pub fn run_js_entrypoint(&mut self, entrypoint: &str) -> Result<ExecutionOutput> {
560        let entry_trimmed = entrypoint.trim();
561        let (module_part, export_part) = entry_trimmed
562            .split_once(':')
563            .map(|(module, export)| (module.trim(), export.trim()))
564            .unwrap_or((entry_trimmed, "default"));
565
566        if module_part.is_empty() {
567            return Err(PyRunnerError::Execution(
568                "entrypoint must include a module name".into(),
569            ));
570        }
571
572        let export_name = if export_part.is_empty() {
573            "default"
574        } else {
575            export_part
576        };
577
578        let mut specifier = module_part.replace('.', "/");
579        if !specifier.ends_with(".js") {
580            specifier.push_str(".js");
581        }
582        let specifier = normalize_specifier(&specifier);
583        self.ensure_module(&specifier)?;
584
585        enum InvocationResult {
586            Immediate(v8::Global<v8::Value>),
587            Promise(v8::Global<Promise>),
588            Exception {
589                typ: String,
590                value: String,
591                stack: Option<String>,
592            },
593        }
594
595        let ctx_state = self.context_state.clone();
596        ctx_state.clear_console();
597
598        self.with_context(|scope, _| {
599            let global = scope.get_current_context().global(scope);
600            let reset_key = v8::String::new(scope, "__aardvarkResetSharedBuffers").unwrap();
601            if let Some(reset_value) = global.get(scope, reset_key.into()) {
602                if let Ok(reset_fn) = Local::<Function>::try_from(reset_value) {
603                    let _ = reset_fn.call(scope, global.into(), &[]);
604                }
605            }
606            Ok(())
607        })?;
608
609        let mut invocation: Option<InvocationResult> = None;
610
611        self.with_context(|scope, _| {
612            v8::tc_scope!(let try_catch, scope);
613            let Some(namespace) = ctx_state.module_namespace(try_catch, &specifier) else {
614                return Err(PyRunnerError::Execution(format!(
615                    "module '{specifier}' not loaded"
616                )));
617            };
618
619            let export_key = v8::String::new(try_catch, export_name)
620                .ok_or_else(|| PyRunnerError::Execution("failed to allocate export name".into()))?;
621            let export_value = namespace.get(try_catch, export_key.into()).ok_or_else(|| {
622                PyRunnerError::Execution(format!(
623                    "module '{specifier}' missing export '{export_name}'"
624                ))
625            })?;
626
627            let Ok(function) = v8::Local::<Function>::try_from(export_value) else {
628                return Err(PyRunnerError::Execution(format!(
629                    "export '{export_name}' is not callable"
630                )));
631            };
632
633            let global = try_catch.get_current_context().global(try_catch);
634            let wrap_key = v8::String::new(try_catch, "__aardvarkWrapRawctxFunction").unwrap();
635            let mut callable = function;
636            if let Some(wrap_value) = global.get(try_catch, wrap_key.into()) {
637                if let Ok(wrap_fn) = v8::Local::<Function>::try_from(wrap_value) {
638                    let module_name = v8::String::new(try_catch, module_part).ok_or_else(|| {
639                        PyRunnerError::Execution("failed to allocate module name".into())
640                    })?;
641                    let export_js = v8::String::new(try_catch, export_name).ok_or_else(|| {
642                        PyRunnerError::Execution("failed to allocate export name".into())
643                    })?;
644                    let wrapped = wrap_fn.call(
645                        try_catch,
646                        global.into(),
647                        &[callable.into(), module_name.into(), export_js.into()],
648                    );
649                    if let Some(value) = wrapped {
650                        if let Ok(func) = v8::Local::<Function>::try_from(value) {
651                            callable = func;
652                        }
653                    }
654                }
655            }
656
657            let call_result = callable.call(try_catch, namespace.into(), &[]);
658            let Some(value) = call_result else {
659                let exception = try_catch.exception();
660                let mut typ = "JavaScriptError".to_string();
661                let mut message = "javascript execution failed".to_string();
662                let mut stack: Option<String> = None;
663
664                if let Some(object) = exception.and_then(|value| value.to_object(try_catch)) {
665                    if let Some(name_value) = object.get(
666                        try_catch,
667                        v8::String::new(try_catch, "name")
668                            .ok_or_else(|| {
669                                PyRunnerError::Execution(
670                                    "failed to allocate error name string".into(),
671                                )
672                            })?
673                            .into(),
674                    ) {
675                        if let Some(name_str) = name_value.to_string(try_catch) {
676                            typ = name_str.to_rust_string_lossy(try_catch);
677                        }
678                    }
679                    if let Some(message_value) = object.get(
680                        try_catch,
681                        v8::String::new(try_catch, "message")
682                            .ok_or_else(|| {
683                                PyRunnerError::Execution(
684                                    "failed to allocate error message string".into(),
685                                )
686                            })?
687                            .into(),
688                    ) {
689                        if let Some(msg_str) = message_value.to_string(try_catch) {
690                            message = msg_str.to_rust_string_lossy(try_catch);
691                        }
692                    }
693                } else if let Some(value) = exception {
694                    if let Some(msg) = value.to_string(try_catch) {
695                        message = msg.to_rust_string_lossy(try_catch);
696                    }
697                }
698
699                if let Some(stack_value) = try_catch.stack_trace() {
700                    if let Some(stack_str) = stack_value.to_string(try_catch) {
701                        stack = Some(stack_str.to_rust_string_lossy(try_catch));
702                    }
703                }
704
705                invocation = Some(InvocationResult::Exception {
706                    typ,
707                    value: message,
708                    stack,
709                });
710                return Ok(());
711            };
712
713            if let Ok(promise) = v8::Local::<Promise>::try_from(value) {
714                invocation = Some(InvocationResult::Promise(v8::Global::new(
715                    try_catch, promise,
716                )));
717            } else {
718                invocation = Some(InvocationResult::Immediate(v8::Global::new(
719                    try_catch, value,
720                )));
721            }
722            Ok(())
723        })?;
724
725        let invocation = invocation.unwrap_or_else(|| InvocationResult::Exception {
726            typ: "JavaScriptError".to_string(),
727            value: "javascript execution failed".to_string(),
728            stack: None,
729        });
730
731        let mut execution = ExecutionOutput {
732            stdout: String::new(),
733            stderr: String::new(),
734            result: None,
735            exception_type: None,
736            exception_value: None,
737            traceback: None,
738            json: None,
739            shared_buffers: Vec::new(),
740        };
741
742        match invocation {
743            InvocationResult::Exception { typ, value, stack } => {
744                execution.exception_type = Some(typ);
745                execution.exception_value = Some(value);
746                execution.traceback = stack;
747            }
748            InvocationResult::Immediate(value_global) => {
749                populate_execution_output(self, value_global, &mut execution)?;
750            }
751            InvocationResult::Promise(promise_global) => {
752                enum PromiseOutcome {
753                    Pending,
754                    Fulfilled(v8::Global<v8::Value>),
755                    Rejected {
756                        typ: String,
757                        value: String,
758                        stack: Option<String>,
759                    },
760                }
761
762                let mut resolved_value: Option<v8::Global<v8::Value>> = None;
763
764                loop {
765                    let outcome = self.with_context(|scope, _| -> Result<PromiseOutcome> {
766                        let promise = v8::Local::new(scope, &promise_global);
767                        match promise.state() {
768                            PromiseState::Pending => Ok(PromiseOutcome::Pending),
769                            PromiseState::Fulfilled => {
770                                let value = promise.result(scope);
771                                Ok(PromiseOutcome::Fulfilled(v8::Global::new(scope, value)))
772                            }
773                            PromiseState::Rejected => {
774                                let reason = promise.result(scope);
775                                let mut typ = "JavaScriptError".to_string();
776                                let mut message = reason
777                                    .to_string(scope)
778                                    .map(|s| s.to_rust_string_lossy(scope))
779                                    .unwrap_or_else(|| "javascript promise rejected".into());
780                                let mut stack: Option<String> = None;
781                                if let Some(object) = reason.to_object(scope) {
782                                    if let Some(name_value) = object.get(
783                                        scope,
784                                        v8::String::new(scope, "name")
785                                            .ok_or_else(|| {
786                                                PyRunnerError::Execution(
787                                                    "failed to allocate error name string".into(),
788                                                )
789                                            })?
790                                            .into(),
791                                    ) {
792                                        if let Some(name_str) = name_value.to_string(scope) {
793                                            typ = name_str.to_rust_string_lossy(scope);
794                                        }
795                                    }
796                                    if let Some(message_value) = object.get(
797                                        scope,
798                                        v8::String::new(scope, "message")
799                                            .ok_or_else(|| {
800                                                PyRunnerError::Execution(
801                                                    "failed to allocate error message string"
802                                                        .into(),
803                                                )
804                                            })?
805                                            .into(),
806                                    ) {
807                                        if let Some(msg_str) = message_value.to_string(scope) {
808                                            message = msg_str.to_rust_string_lossy(scope);
809                                        }
810                                    }
811                                    if let Some(stack_value) = object.get(
812                                        scope,
813                                        v8::String::new(scope, "stack")
814                                            .ok_or_else(|| {
815                                                PyRunnerError::Execution(
816                                                    "failed to allocate error stack string".into(),
817                                                )
818                                            })?
819                                            .into(),
820                                    ) {
821                                        if let Some(stack_str) = stack_value.to_string(scope) {
822                                            stack = Some(stack_str.to_rust_string_lossy(scope));
823                                        }
824                                    }
825                                }
826                                Ok(PromiseOutcome::Rejected {
827                                    typ,
828                                    value: message,
829                                    stack,
830                                })
831                            }
832                        }
833                    })?;
834
835                    match outcome {
836                        PromiseOutcome::Pending => {
837                            self.isolate.perform_microtask_checkpoint();
838                        }
839                        PromiseOutcome::Fulfilled(value) => {
840                            resolved_value = Some(value);
841                            break;
842                        }
843                        PromiseOutcome::Rejected { typ, value, stack } => {
844                            execution.exception_type = Some(typ);
845                            execution.exception_value = Some(value);
846                            execution.traceback = stack;
847                            break;
848                        }
849                    }
850                }
851
852                if let Some(value_global) = resolved_value {
853                    populate_execution_output(self, value_global, &mut execution)?;
854                }
855            }
856        }
857
858        let shared_buffers = self.with_context(|scope, _| -> Result<Vec<SharedBuffer>> {
859            let global = scope.get_current_context().global(scope);
860            let buffers = collect_shared_buffers(scope, global)?;
861            if !buffers.is_empty() {
862                let release_ids: Vec<String> =
863                    buffers.iter().map(|buffer| buffer.id.clone()).collect();
864                release_shared_buffers(scope, global, &release_ids)?;
865            }
866            Ok(buffers)
867        })?;
868        execution.shared_buffers = shared_buffers;
869
870        execution.stdout = ctx_state.take_stdout();
871        execution.stderr = ctx_state.take_stderr();
872
873        Ok(execution)
874    }
875
876    /// Clears published shared buffers between invocations.
877    pub fn reset_shared_buffers(&mut self) -> Result<()> {
878        self.with_context(|scope, _| {
879            let global = scope.get_current_context().global(scope);
880            let key = v8::String::new(scope, "__aardvarkResetSharedBuffers").ok_or_else(|| {
881                PyRunnerError::Execution("shared buffer reset hook unavailable".into())
882            })?;
883            if let Some(value) = global.get(scope, key.into()) {
884                if let Ok(func) = Local::<Function>::try_from(value) {
885                    func.call(scope, global.into(), &[]).ok_or_else(|| {
886                        PyRunnerError::Execution("shared buffer reset failed".into())
887                    })?;
888                }
889            }
890            Ok(())
891        })
892    }
893
894    /// Resets the session scratch filesystem after an invocation completes.
895    pub fn reset_filesystem(&mut self) -> Result<()> {
896        self.with_context(|scope, _| {
897            let global = scope.get_current_context().global(scope);
898            let key = v8::String::new(scope, "__aardvarkFilesystemReset").ok_or_else(|| {
899                PyRunnerError::Execution("filesystem reset hook unavailable".into())
900            })?;
901            let value = global.get(scope, key.into()).ok_or_else(|| {
902                PyRunnerError::Execution("__aardvarkFilesystemReset missing".into())
903            })?;
904            let func = Local::<Function>::try_from(value).map_err(|_| {
905                PyRunnerError::Execution("__aardvarkFilesystemReset is not a function".into())
906            })?;
907            func.call(scope, global.into(), &[])
908                .ok_or_else(|| PyRunnerError::Execution("filesystem reset failed".into()))?;
909            Ok(())
910        })
911    }
912
913    /// Returns the current byte usage of the session scratch filesystem.
914    pub fn filesystem_usage_bytes(&mut self) -> Result<u64> {
915        self.with_context(|scope, _| {
916            let global = scope.get_current_context().global(scope);
917            let key = v8::String::new(scope, "__aardvarkFilesystemGetUsage").ok_or_else(|| {
918                PyRunnerError::Execution("filesystem usage hook unavailable".into())
919            })?;
920            let value = global.get(scope, key.into()).ok_or_else(|| {
921                PyRunnerError::Execution("__aardvarkFilesystemGetUsage missing".into())
922            })?;
923            let func = Local::<Function>::try_from(value).map_err(|_| {
924                PyRunnerError::Execution("__aardvarkFilesystemGetUsage is not a function".into())
925            })?;
926            let usage_value = func
927                .call(scope, global.into(), &[])
928                .ok_or_else(|| PyRunnerError::Execution("filesystem usage query failed".into()))?;
929            let number = usage_value
930                .to_number(scope)
931                .ok_or_else(|| PyRunnerError::Execution("filesystem usage not a number".into()))?;
932            let value = number.value();
933            Ok(if value <= 0.0 { 0 } else { value as u64 })
934        })
935    }
936
937    /// Applies the active host capabilities for native APIs exposed to guest code.
938    pub fn set_host_capabilities(&mut self, capabilities: &[String]) -> Result<()> {
939        self.with_context(|scope, _| {
940            let global = scope.get_current_context().global(scope);
941            let key = v8::String::new(scope, "__aardvarkSetHostCapabilities").ok_or_else(|| {
942                PyRunnerError::Execution("host capability hook unavailable".into())
943            })?;
944            let value = global.get(scope, key.into()).ok_or_else(|| {
945                PyRunnerError::Execution("__aardvarkSetHostCapabilities missing".into())
946            })?;
947            let func = Local::<Function>::try_from(value).map_err(|_| {
948                PyRunnerError::Execution("__aardvarkSetHostCapabilities is not a function".into())
949            })?;
950            let array = v8::Array::new(scope, capabilities.len() as i32);
951            for (index, capability) in capabilities.iter().enumerate() {
952                if let Some(value) = v8::String::new(scope, capability) {
953                    array.set_index(scope, index as u32, value.into());
954                }
955            }
956            func.call(scope, global.into(), &[array.into()])
957                .ok_or_else(|| {
958                    PyRunnerError::Execution("applying host capabilities failed".into())
959                })?;
960            Ok(())
961        })
962    }
963
964    pub(crate) fn isolate_handle(&mut self) -> v8::IsolateHandle {
965        self.isolate.thread_safe_handle()
966    }
967
968    pub(crate) fn cancel_terminate_execution(&mut self) {
969        let _ = self.isolate.cancel_terminate_execution();
970    }
971
972    pub(crate) fn heap_used_bytes(&mut self) -> usize {
973        let stats = self.isolate.get_heap_statistics();
974        stats.used_heap_size()
975    }
976
977    fn install_polyfills(&mut self) -> Result<()> {
978        self.with_context(|scope, context| {
979            let global = context.global(scope);
980            let source = include_str!("js/polyfills.js");
981            let script_name = "polyfills.js";
982            exec_script(scope, script_name, source)
983                .map_err(|msg| PyRunnerError::Init(format!("polyfill error: {msg}")))?;
984            // Attach hooks placeholder for future assets.
985            let fetch_name = v8::String::new(scope, "__pyRunnerFetchAsset")
986                .ok_or_else(|| PyRunnerError::Init("failed to allocate v8 string".into()))?;
987            let template = v8::FunctionTemplate::new(scope, asset_fetch_callback);
988            let function = template
989                .get_function(scope)
990                .ok_or_else(|| PyRunnerError::Init("failed to create asset hook".into()))?;
991            let _ = global.set(scope, fetch_name.into(), function.into());
992
993            let log_name = v8::String::new(scope, "__pyRunnerNativeLog")
994                .ok_or_else(|| PyRunnerError::Init("failed to allocate log string".into()))?;
995            let log_template = v8::FunctionTemplate::new(scope, native_log_callback);
996            let log_function = log_template
997                .get_function(scope)
998                .ok_or_else(|| PyRunnerError::Init("failed to create log hook".into()))?;
999            let _ = global.set(scope, log_name.into(), log_function.into());
1000
1001            let native_fetch_name =
1002                v8::String::new(scope, "__pyRunnerNativeFetch").ok_or_else(|| {
1003                    PyRunnerError::Init("failed to allocate native fetch string".into())
1004                })?;
1005            let native_fetch_template = v8::FunctionTemplate::new(scope, native_fetch_callback);
1006            let native_fetch_function = native_fetch_template
1007                .get_function(scope)
1008                .ok_or_else(|| PyRunnerError::Init("failed to create native fetch hook".into()))?;
1009            let _ = global.set(
1010                scope,
1011                native_fetch_name.into(),
1012                native_fetch_function.into(),
1013            );
1014
1015            let record_name =
1016                v8::String::new(scope, "__aardvarkRecordBufferEvent").ok_or_else(|| {
1017                    PyRunnerError::Init("failed to allocate buffer event string".into())
1018                })?;
1019            let record_template = v8::FunctionTemplate::new(scope, record_buffer_event_callback);
1020            let record_function = record_template
1021                .get_function(scope)
1022                .ok_or_else(|| PyRunnerError::Init("failed to create buffer event hook".into()))?;
1023            let _ = global.set(scope, record_name.into(), record_function.into());
1024
1025            let fs_violation_name = v8::String::new(scope, "__aardvarkFilesystemRecordViolation")
1026                .ok_or_else(|| {
1027                PyRunnerError::Init("failed to allocate fs violation string".into())
1028            })?;
1029            let fs_violation_template =
1030                v8::FunctionTemplate::new(scope, filesystem_violation_callback);
1031            let fs_violation_function =
1032                fs_violation_template.get_function(scope).ok_or_else(|| {
1033                    PyRunnerError::Init("failed to create filesystem violation hook".into())
1034                })?;
1035            let _ = global.set(
1036                scope,
1037                fs_violation_name.into(),
1038                fs_violation_function.into(),
1039            );
1040            Ok(())
1041        })
1042    }
1043
1044    /// Execute a script within the isolate.
1045    pub fn execute_script(&mut self, name: &str, source: &str) -> Result<()> {
1046        self.with_context(|scope, _context| {
1047            exec_script(scope, name, source)
1048                .map_err(|msg| PyRunnerError::Execution(format!("javascript error: {msg}")))
1049        })
1050    }
1051
1052    /// Utility to run closures with a borrowed handle scope inside the runtime context.
1053    pub fn with_context<F, R>(&mut self, f: F) -> Result<R>
1054    where
1055        F: for<'a> FnOnce(&mut PinScope<'a, '_>, Local<'a, v8::Context>) -> Result<R>,
1056    {
1057        let context_global = self.context.clone();
1058        v8::scope!(let isolate_scope, &mut self.isolate);
1059        let context = v8::Local::new(isolate_scope, context_global);
1060        let mut context_scope = ContextScope::new(isolate_scope, context);
1061        f(&mut context_scope, context)
1062    }
1063
1064    /// Registers a text asset in the runtime asset store.
1065    pub fn insert_text_asset<S>(&self, name: &str, contents: S)
1066    where
1067        S: Into<Arc<str>>,
1068    {
1069        self.context_state.assets.insert_text(name, contents.into());
1070    }
1071
1072    /// Registers a binary asset in the runtime asset store.
1073    pub fn insert_binary_asset(&self, name: &str, bytes: &'static [u8]) {
1074        self.context_state
1075            .assets
1076            .insert_bytes(name, Arc::<[u8]>::from(bytes));
1077    }
1078
1079    /// Registers a binary asset backed by an owned buffer.
1080    pub fn insert_binary_asset_owned(&self, name: &str, bytes: Vec<u8>) {
1081        self.context_state
1082            .assets
1083            .insert_bytes(name, Arc::<[u8]>::from(bytes.into_boxed_slice()));
1084    }
1085
1086    /// Loads the Pyodide runtime by calling the embedded loader module.
1087    pub fn load_pyodide(&mut self, options: PyodideLoadOptions<'_>) -> Result<()> {
1088        if self.context_state.pyodide_instance.borrow().is_some() {
1089            return Ok(());
1090        }
1091        let ctx_state = self.context_state.clone();
1092        self.ensure_module("pyodide.mjs")?;
1093        self.ensure_module("pyodide_bootstrap.js")?;
1094        let mut promise_handle: Option<v8::Global<Promise>> = None;
1095        self.with_context(|scope, _| {
1096            let bootstrap = ctx_state
1097                .module_namespace(scope, "pyodide_bootstrap.js")
1098                .ok_or_else(|| {
1099                    PyRunnerError::Execution("pyodide_bootstrap.js namespace unavailable".into())
1100                })?;
1101            let load_key = v8::String::new(scope, "loadPyRunnerPyodide").unwrap();
1102            let load_value = bootstrap.get(scope, load_key.into());
1103            let load_fn = load_value
1104                .and_then(|value| Local::<Function>::try_from(value).ok())
1105                .ok_or_else(|| {
1106                    PyRunnerError::Execution(
1107                        "pyodide_bootstrap.js does not export loadPyRunnerPyodide".into(),
1108                    )
1109                })?;
1110            let js_options = v8::Object::new(scope);
1111            let index_key = v8::String::new(scope, "indexURL").unwrap();
1112            let index_value = v8::String::new(scope, ".").unwrap();
1113            let _ = js_options.set(scope, index_key.into(), index_value.into());
1114            if let Some(snapshot) = options.snapshot {
1115                let snapshot_key = v8::String::new(scope, "snapshot").unwrap();
1116                let backing = v8::ArrayBuffer::new_backing_store_from_vec(snapshot.to_vec());
1117                let shared = backing.make_shared();
1118                let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &shared);
1119                let length = array_buffer.byte_length();
1120                let typed = Uint8Array::new(scope, array_buffer, 0, length).unwrap();
1121                let _ = js_options.set(scope, snapshot_key.into(), typed.into());
1122            }
1123            if options.make_snapshot {
1124                let make_key = v8::String::new(scope, "makeSnapshot").unwrap();
1125                let make_value = v8::Boolean::new(scope, true);
1126                let _ = js_options.set(scope, make_key.into(), make_value.into());
1127            }
1128            let value = load_fn
1129                .call(scope, bootstrap.into(), &[js_options.into()])
1130                .ok_or_else(|| PyRunnerError::Execution("loadPyodide invocation failed".into()))?;
1131            let promise = v8::Local::<Promise>::try_from(value).map_err(|_| {
1132                PyRunnerError::Execution("loadPyodide did not return a Promise".into())
1133            })?;
1134            promise_handle = Some(v8::Global::new(scope, promise));
1135            Ok(())
1136        })?;
1137
1138        let promise_global = promise_handle
1139            .ok_or_else(|| PyRunnerError::Execution("missing loadPyodide promise handle".into()))?;
1140
1141        loop {
1142            let done = self.with_context(|scope, _| -> Result<Option<()>> {
1143                let promise = v8::Local::new(scope, &promise_global);
1144                match promise.state() {
1145                    PromiseState::Pending => Ok(None),
1146                    PromiseState::Fulfilled => {
1147                        let result = promise.result(scope);
1148                        let obj = v8::Local::<Object>::try_from(result).map_err(|_| {
1149                            PyRunnerError::Execution(
1150                                "loadPyodide fulfilled with non-object result".into(),
1151                            )
1152                        })?;
1153                        ctx_state
1154                            .pyodide_instance
1155                            .replace(Some(v8::Global::new(scope, obj)));
1156                        Ok(Some(()))
1157                    }
1158                    PromiseState::Rejected => {
1159                        let reason = promise.result(scope);
1160                        let message = reason
1161                            .to_string(scope)
1162                            .map(|s| s.to_rust_string_lossy(scope))
1163                            .unwrap_or_else(|| "unknown rejection".to_string());
1164                        let detailed = reason
1165                            .to_object(scope)
1166                            .and_then(|obj| {
1167                                let stack_key = v8::String::new(scope, "stack")?;
1168                                obj.get(scope, stack_key.into())
1169                            })
1170                            .and_then(|value| value.to_string(scope))
1171                            .map(|s| s.to_rust_string_lossy(scope));
1172                        let message = detailed
1173                            .map(|stack| format!("{message}\n{stack}"))
1174                            .unwrap_or(message);
1175                        Err(PyRunnerError::Execution(format!(
1176                            "loadPyodide rejected: {message}"
1177                        )))
1178                    }
1179                }
1180            })?;
1181            if done.is_some() {
1182                break;
1183            }
1184            self.isolate.perform_microtask_checkpoint();
1185        }
1186
1187        self.prepare_dynlibs()?;
1188        Ok(())
1189    }
1190
1191    /// Invokes the JS helper to load one or more Pyodide packages via the package manager.
1192    pub fn load_packages(&mut self, packages: &[String]) -> Result<()> {
1193        if packages.is_empty() {
1194            return Ok(());
1195        }
1196
1197        let mut promise_handle: Option<v8::Global<Promise>> = None;
1198        self.with_context(|scope, _| {
1199            let global = scope.get_current_context().global(scope);
1200            let key = v8::String::new(scope, "__pyRunnerLoadPackages").ok_or_else(|| {
1201                PyRunnerError::Execution("failed to allocate package loader key".into())
1202            })?;
1203            let value = global.get(scope, key.into()).ok_or_else(|| {
1204                PyRunnerError::Execution("__pyRunnerLoadPackages is not defined".into())
1205            })?;
1206            let func = Local::<Function>::try_from(value).map_err(|_| {
1207                PyRunnerError::Execution("__pyRunnerLoadPackages is not a function".into())
1208            })?;
1209
1210            let array = v8::Array::new(scope, packages.len() as i32);
1211            for (index, name) in packages.iter().enumerate() {
1212                let value = v8::String::new(scope, name).ok_or_else(|| {
1213                    PyRunnerError::Execution("failed to allocate package name".into())
1214                })?;
1215                array.set_index(scope, index as u32, value.into());
1216            }
1217
1218            let promise_value = func
1219                .call(scope, global.into(), &[array.into()])
1220                .ok_or_else(|| {
1221                    PyRunnerError::Execution("package loader invocation failed".into())
1222                })?;
1223            let promise = v8::Local::<Promise>::try_from(promise_value).map_err(|_| {
1224                PyRunnerError::Execution("package loader did not return a Promise".into())
1225            })?;
1226            promise_handle = Some(v8::Global::new(scope, promise));
1227            Ok(())
1228        })?;
1229
1230        let promise_global = promise_handle.ok_or_else(|| {
1231            PyRunnerError::Execution("missing package loader promise handle".into())
1232        })?;
1233
1234        loop {
1235            let done = self.with_context(|scope, _| -> Result<Option<()>> {
1236                let promise = v8::Local::new(scope, &promise_global);
1237                match promise.state() {
1238                    PromiseState::Pending => Ok(None),
1239                    PromiseState::Fulfilled => Ok(Some(())),
1240                    PromiseState::Rejected => {
1241                        let reason = promise.result(scope);
1242                        let message = reason
1243                            .to_string(scope)
1244                            .map(|s| s.to_rust_string_lossy(scope))
1245                            .unwrap_or_else(|| "unknown rejection".to_string());
1246                        let detailed = reason
1247                            .to_object(scope)
1248                            .and_then(|obj| {
1249                                let stack_key = v8::String::new(scope, "stack")?;
1250                                obj.get(scope, stack_key.into())
1251                            })
1252                            .and_then(|value| value.to_string(scope))
1253                            .map(|s| s.to_rust_string_lossy(scope));
1254                        let message = detailed
1255                            .map(|stack| format!("{message}\n{stack}"))
1256                            .unwrap_or(message);
1257                        Err(PyRunnerError::Execution(format!(
1258                            "loadPackages rejected: {message}"
1259                        )))
1260                    }
1261                }
1262            })?;
1263            if done.is_some() {
1264                break;
1265            }
1266            self.isolate.perform_microtask_checkpoint();
1267        }
1268
1269        Ok(())
1270    }
1271
1272    /// Captures a Pyodide memory snapshot and returns the raw bytes.
1273    pub fn collect_snapshot(&mut self) -> Result<Vec<u8>> {
1274        let mut snapshot: Option<Vec<u8>> = None;
1275        self.with_context(|scope, _| {
1276            let global = scope.get_current_context().global(scope);
1277            let key = v8::String::new(scope, "__pyRunnerMakeSnapshot").ok_or_else(|| {
1278                PyRunnerError::Execution("failed to allocate snapshot key".into())
1279            })?;
1280            let value = global.get(scope, key.into()).ok_or_else(|| {
1281                PyRunnerError::Execution("__pyRunnerMakeSnapshot is not defined".into())
1282            })?;
1283            let func = Local::<Function>::try_from(value).map_err(|_| {
1284                PyRunnerError::Execution("__pyRunnerMakeSnapshot is not a function".into())
1285            })?;
1286            let result = func
1287                .call(scope, global.into(), &[])
1288                .ok_or_else(|| PyRunnerError::Execution("snapshot invocation failed".into()))?;
1289            let array = Local::<Uint8Array>::try_from(result).map_err(|_| {
1290                PyRunnerError::Execution(
1291                    "__pyRunnerMakeSnapshot did not return a Uint8Array".into(),
1292                )
1293            })?;
1294            snapshot = Some(copy_typed_array(array));
1295            Ok(())
1296        })?;
1297
1298        snapshot.ok_or_else(|| PyRunnerError::Execution("snapshot helper returned no data".into()))
1299    }
1300
1301    /// Exports the overlay metadata (site-packages + /usr/lib) and tar payload.
1302    pub fn export_overlay(&mut self) -> Result<OverlayExport> {
1303        let mut metadata: Option<Vec<u8>> = None;
1304        let mut blobs: Vec<OverlayBlob> = Vec::new();
1305        self.with_context(|scope, _| {
1306            let global = scope.get_current_context().global(scope);
1307            let key_meta = v8::String::new(scope, "__pyRunnerExportOverlay").ok_or_else(|| {
1308                PyRunnerError::Execution("failed to allocate overlay export key".into())
1309            })?;
1310            let value_meta = global.get(scope, key_meta.into()).ok_or_else(|| {
1311                PyRunnerError::Execution("__pyRunnerExportOverlay is not defined".into())
1312            })?;
1313            let func_meta = Local::<Function>::try_from(value_meta).map_err(|_| {
1314                PyRunnerError::Execution("__pyRunnerExportOverlay is not a function".into())
1315            })?;
1316            let meta_result = func_meta
1317                .call(scope, global.into(), &[])
1318                .ok_or_else(|| PyRunnerError::Execution("overlay export failed".into()))?;
1319            if meta_result.is_null_or_undefined() {
1320                metadata = Some(Vec::new());
1321            } else {
1322                let array = Local::<Uint8Array>::try_from(meta_result).map_err(|_| {
1323                    PyRunnerError::Execution(
1324                        "__pyRunnerExportOverlay did not return a Uint8Array".into(),
1325                    )
1326                })?;
1327                metadata = Some(copy_typed_array(array));
1328            }
1329
1330            let key_blobs =
1331                v8::String::new(scope, "__pyRunnerExportOverlayBlobs").ok_or_else(|| {
1332                    PyRunnerError::Execution("failed to allocate overlay blob export key".into())
1333                })?;
1334            let value_blobs = global.get(scope, key_blobs.into()).ok_or_else(|| {
1335                PyRunnerError::Execution("__pyRunnerExportOverlayBlobs is not defined".into())
1336            })?;
1337            let func_blobs = Local::<Function>::try_from(value_blobs).map_err(|_| {
1338                PyRunnerError::Execution("__pyRunnerExportOverlayBlobs is not a function".into())
1339            })?;
1340            let blobs_result = func_blobs
1341                .call(scope, global.into(), &[])
1342                .ok_or_else(|| PyRunnerError::Execution("overlay blob export failed".into()))?;
1343            if !blobs_result.is_null_or_undefined() {
1344                let array = Local::<v8::Array>::try_from(blobs_result).map_err(|_| {
1345                    PyRunnerError::Execution(
1346                        "__pyRunnerExportOverlayBlobs did not return an Array".into(),
1347                    )
1348                })?;
1349                let key_prop = v8::String::new(scope, "key").ok_or_else(|| {
1350                    PyRunnerError::Execution("failed to allocate blob key".into())
1351                })?;
1352                let digest_prop = v8::String::new(scope, "digest").ok_or_else(|| {
1353                    PyRunnerError::Execution("failed to allocate blob digest".into())
1354                })?;
1355                let data_prop = v8::String::new(scope, "data").ok_or_else(|| {
1356                    PyRunnerError::Execution("failed to allocate blob data".into())
1357                })?;
1358                let length = array.length();
1359                for index in 0..length {
1360                    let value = array
1361                        .get_index(scope, index)
1362                        .unwrap_or_else(|| v8::undefined(scope).into());
1363                    if value.is_null_or_undefined() {
1364                        continue;
1365                    }
1366                    let object = Local::<Object>::try_from(value).map_err(|_| {
1367                        PyRunnerError::Execution("overlay blob entry is not an object".into())
1368                    })?;
1369                    let key_value = object
1370                        .get(scope, key_prop.into())
1371                        .unwrap_or_else(|| v8::undefined(scope).into());
1372                    let key = if key_value.is_null_or_undefined() {
1373                        String::new()
1374                    } else {
1375                        key_value
1376                            .to_string(scope)
1377                            .ok_or_else(|| {
1378                                PyRunnerError::Execution(
1379                                    "failed to convert overlay blob key to string".into(),
1380                                )
1381                            })?
1382                            .to_rust_string_lossy(scope)
1383                    };
1384                    let digest_value = object
1385                        .get(scope, digest_prop.into())
1386                        .unwrap_or_else(|| v8::undefined(scope).into());
1387                    let digest = if digest_value.is_null_or_undefined() {
1388                        None
1389                    } else {
1390                        Some(
1391                            digest_value
1392                                .to_string(scope)
1393                                .ok_or_else(|| {
1394                                    PyRunnerError::Execution(
1395                                        "failed to convert overlay blob digest to string".into(),
1396                                    )
1397                                })?
1398                                .to_rust_string_lossy(scope),
1399                        )
1400                    };
1401                    let data_value = object.get(scope, data_prop.into()).ok_or_else(|| {
1402                        PyRunnerError::Execution(
1403                            "overlay blob entry missing 'data' property".into(),
1404                        )
1405                    })?;
1406                    let data_array = Local::<Uint8Array>::try_from(data_value).map_err(|_| {
1407                        PyRunnerError::Execution("overlay blob 'data' is not a Uint8Array".into())
1408                    })?;
1409                    let bytes = copy_typed_array(data_array);
1410                    blobs.push(OverlayBlob { key, digest, bytes });
1411                }
1412            }
1413            Ok(())
1414        })?;
1415
1416        Ok(OverlayExport {
1417            metadata: metadata.ok_or_else(|| {
1418                PyRunnerError::Execution("overlay export returned no data".into())
1419            })?,
1420            blobs,
1421        })
1422    }
1423
1424    /// Imports overlay metadata and refreshes the dynamic library bindings.
1425    pub fn import_overlay(&mut self, metadata: &[u8], blobs: &[OverlayBlob]) -> Result<()> {
1426        self.with_context(|scope, _| {
1427            let global = scope.get_current_context().global(scope);
1428            let key = v8::String::new(scope, "__pyRunnerImportOverlay").ok_or_else(|| {
1429                PyRunnerError::Execution("failed to allocate overlay import key".into())
1430            })?;
1431            let value = global.get(scope, key.into()).ok_or_else(|| {
1432                PyRunnerError::Execution("__pyRunnerImportOverlay is not defined".into())
1433            })?;
1434            let func = Local::<Function>::try_from(value).map_err(|_| {
1435                PyRunnerError::Execution("__pyRunnerImportOverlay is not a function".into())
1436            })?;
1437            let meta_backing = v8::ArrayBuffer::new_backing_store_from_vec(metadata.to_vec());
1438            let meta_shared = meta_backing.make_shared();
1439            let meta_buffer = v8::ArrayBuffer::with_backing_store(scope, &meta_shared);
1440            let meta_typed =
1441                Uint8Array::new(scope, meta_buffer, 0, metadata.len()).ok_or_else(|| {
1442                    PyRunnerError::Execution("failed to allocate overlay metadata buffer".into())
1443                })?;
1444
1445            let blob_array = v8::Array::new(scope, blobs.len() as i32);
1446            let key_prop = v8::String::new(scope, "key")
1447                .ok_or_else(|| PyRunnerError::Execution("failed to allocate blob key".into()))?;
1448            let digest_prop = v8::String::new(scope, "digest")
1449                .ok_or_else(|| PyRunnerError::Execution("failed to allocate blob digest".into()))?;
1450            let data_prop = v8::String::new(scope, "data")
1451                .ok_or_else(|| PyRunnerError::Execution("failed to allocate blob data".into()))?;
1452            for (index, blob) in blobs.iter().enumerate() {
1453                let object = v8::Object::new(scope);
1454                let key_string = v8::String::new(scope, blob.key.as_str())
1455                    .ok_or_else(|| PyRunnerError::Execution("failed to convert blob key".into()))?;
1456                object.set(scope, key_prop.into(), key_string.into());
1457                if let Some(digest) = &blob.digest {
1458                    let digest_string =
1459                        v8::String::new(scope, digest.as_str()).ok_or_else(|| {
1460                            PyRunnerError::Execution("failed to convert blob digest".into())
1461                        })?;
1462                    object.set(scope, digest_prop.into(), digest_string.into());
1463                } else {
1464                    let null_value = v8::null(scope);
1465                    object.set(scope, digest_prop.into(), null_value.into());
1466                }
1467                let data_backing = v8::ArrayBuffer::new_backing_store_from_vec(blob.bytes.clone());
1468                let data_shared = data_backing.make_shared();
1469                let data_buffer = v8::ArrayBuffer::with_backing_store(scope, &data_shared);
1470                let data_array = Uint8Array::new(scope, data_buffer, 0, blob.bytes.len())
1471                    .ok_or_else(|| {
1472                        PyRunnerError::Execution(
1473                            "failed to allocate overlay blob data buffer".into(),
1474                        )
1475                    })?;
1476                object.set(scope, data_prop.into(), data_array.into());
1477                blob_array.set_index(scope, index as u32, object.into());
1478            }
1479
1480            let _ = func.call(
1481                scope,
1482                global.into(),
1483                &[meta_typed.into(), blob_array.into()],
1484            );
1485            Ok(())
1486        })?;
1487        self.prepare_dynlibs()
1488    }
1489
1490    /// Refreshes dynamic library bindings after package or snapshot operations.
1491    pub fn prepare_dynlibs(&mut self) -> Result<()> {
1492        self.with_context(|scope, _| {
1493            let global = scope.get_current_context().global(scope);
1494            let key = v8::String::new(scope, "__pyRunnerPrepareDynlibs").ok_or_else(|| {
1495                PyRunnerError::Execution("failed to allocate dynlib preparation key".into())
1496            })?;
1497            let value = global.get(scope, key.into()).ok_or_else(|| {
1498                PyRunnerError::Execution("__pyRunnerPrepareDynlibs is not defined".into())
1499            })?;
1500            let func = Local::<Function>::try_from(value).map_err(|_| {
1501                PyRunnerError::Execution("__pyRunnerPrepareDynlibs is not a function".into())
1502            })?;
1503            let _ = func.call(scope, global.into(), &[]);
1504            Ok(())
1505        })
1506    }
1507
1508    /// Mounts bundle files into the Pyodide virtual filesystem at the given root directory.
1509    pub fn mount_bundle(&mut self, bundle: &Bundle, root: &str) -> Result<()> {
1510        let ctx_state = self.context_state.clone();
1511        let root_owned = root.to_owned();
1512        self.with_context(|scope, _| {
1513            let pyodide = ctx_state
1514                .pyodide_local(scope)
1515                .ok_or_else(|| PyRunnerError::Execution("Pyodide is not loaded".into()))?;
1516
1517            let files = v8::Array::new(scope, bundle.entries().len() as i32);
1518            for (index, entry) in bundle.entries().iter().enumerate() {
1519                let obj = v8::Object::new(scope);
1520                let rel_path = v8::String::new(scope, entry.path()).ok_or_else(|| {
1521                    PyRunnerError::Execution("failed to allocate file path string".into())
1522                })?;
1523                let path_key = v8::String::new(scope, "path").unwrap();
1524                let data_key = v8::String::new(scope, "data").unwrap();
1525                let size_key = v8::String::new(scope, "size").unwrap();
1526
1527                let buffer = entry.contents().to_vec();
1528                let backing = v8::ArrayBuffer::new_backing_store_from_bytes(buffer);
1529                let shared = backing.make_shared();
1530                let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &shared);
1531                let uint8 = Uint8Array::new(scope, array_buffer, 0, entry.contents().len())
1532                    .ok_or_else(|| {
1533                        PyRunnerError::Execution("failed to allocate typed array".into())
1534                    })?;
1535                let size_value = v8::Number::new(scope, entry.contents().len() as f64);
1536
1537                let _ = obj.set(scope, path_key.into(), rel_path.into());
1538                let _ = obj.set(scope, data_key.into(), uint8.into());
1539                let _ = obj.set(scope, size_key.into(), size_value.into());
1540                files.set_index(scope, index as u32, obj.into());
1541            }
1542
1543            let global = scope.get_current_context().global(scope);
1544            let mount_fn_key = v8::String::new(scope, "__pyRunnerMountFiles").unwrap();
1545            let mount_fn_value = global
1546                .get(scope, mount_fn_key.into())
1547                .ok_or_else(|| {
1548                    PyRunnerError::Execution("__pyRunnerMountFiles is not defined".into())
1549                })?;
1550            let mount_fn = Local::<Function>::try_from(mount_fn_value).map_err(|_| {
1551                PyRunnerError::Execution("__pyRunnerMountFiles is not a function".into())
1552            })?;
1553            let root_value = v8::String::new(scope, &root_owned).ok_or_else(|| {
1554                PyRunnerError::Execution("failed to allocate mount root string".into())
1555            })?;
1556            mount_fn
1557                .call(scope, global.into(), &[pyodide.into(), files.into(), root_value.into()])
1558                .ok_or_else(|| PyRunnerError::Execution("mount files call failed".into()))?;
1559
1560            let run_key = v8::String::new(scope, "runPython").unwrap();
1561            let run_value = pyodide.get(scope, run_key.into()).ok_or_else(|| {
1562                PyRunnerError::Execution("pyodide.runPython is not available".into())
1563            })?;
1564            let run_fn = Local::<Function>::try_from(run_value).map_err(|_| {
1565                PyRunnerError::Execution("pyodide.runPython is not a function".into())
1566            })?;
1567            let script = v8::String::new(
1568                scope,
1569                "import sys\nfrom pathlib import Path\napp = Path('/app')\nif str(app) not in sys.path:\n    sys.path.insert(0, str(app))\n",
1570            )
1571            .ok_or_else(|| PyRunnerError::Execution("failed to allocate sys.path script".into()))?;
1572            let _ = run_fn.call(scope, pyodide.into(), &[script.into()]);
1573
1574            Ok(())
1575        })?;
1576        Ok(())
1577    }
1578
1579    /// Executes the specified Python module/function entrypoint, capturing stdout and stderr.
1580    pub fn run_python_entrypoint(&mut self, entrypoint: &str) -> Result<ExecutionOutput> {
1581        let ctx_state = self.context_state.clone();
1582        let entry_literal = serde_json::to_string(entrypoint).map_err(|err| {
1583            PyRunnerError::Execution(format!("failed to encode entrypoint: {err}"))
1584        })?;
1585        let script = format!(
1586            "import io, sys, importlib, json, traceback\nfrom js import globalThis as __aardvark_js\nif '__aardvark_publish_buffer' not in globals():\n    def __aardvark_publish_buffer(buffer_id, data, metadata=None):\n        return __aardvark_js.__aardvarkPublishBuffer(buffer_id, data, metadata)\nentrypoint = {entry}\n_stdout = io.StringIO()\n_stderr = io.StringIO()\n_old_out, _old_err = sys.stdout, sys.stderr\nresult_repr = None\nexc_type = None\nexc_value = None\nexc_traceback = None\ntry:\n    sys.stdout = _stdout\n    sys.stderr = _stderr\n    module_name, sep, func_name = entrypoint.partition(':')\n    if not module_name:\n        raise ValueError('entrypoint must specify a module')\n    module = importlib.import_module(module_name)\n    if sep:\n        target = getattr(module, func_name)\n        value = target()\n    elif hasattr(module, 'main'):\n        value = module.main()\n    else:\n        value = None\n    result_repr = repr(value)\nexcept Exception as exc:  # noqa: BLE001\n    exc_type = exc.__class__.__name__\n    exc_value = repr(exc)\n    exc_traceback = traceback.format_exc()\nfinally:\n    sys.stdout = _old_out\n    sys.stderr = _old_err\njson.dumps({{\"stdout\": _stdout.getvalue(), \"stderr\": _stderr.getvalue(), \"result\": result_repr, \"exception_type\": exc_type, \"exception_value\": exc_value, \"traceback\": exc_traceback}})\n",
1587            entry = entry_literal
1588        );
1589
1590        self.with_context(|scope, _| {
1591            let pyodide = ctx_state
1592                .pyodide_local(scope)
1593                .ok_or_else(|| PyRunnerError::Execution("Pyodide is not loaded".into()))?;
1594            let global = scope.get_current_context().global(scope);
1595            let request_key = v8::String::new(scope, "__pyRunnerEnterRequestContext").unwrap();
1596            if let Some(request_value) = global.get(scope, request_key.into()) {
1597                if let Ok(request_fn) = Local::<Function>::try_from(request_value) {
1598                    let _ = request_fn.call(scope, global.into(), &[]);
1599                }
1600            }
1601            let reset_key = v8::String::new(scope, "__aardvarkResetSharedBuffers").unwrap();
1602            if let Some(reset_value) = global.get(scope, reset_key.into()) {
1603                if let Ok(reset_fn) = Local::<Function>::try_from(reset_value) {
1604                    let _ = reset_fn.call(scope, global.into(), &[]);
1605                }
1606            }
1607            let run_key = v8::String::new(scope, "runPython").unwrap();
1608            let run_value = pyodide.get(scope, run_key.into()).ok_or_else(|| {
1609                PyRunnerError::Execution("pyodide.runPython is not available".into())
1610            })?;
1611            let run_fn = Local::<Function>::try_from(run_value).map_err(|_| {
1612                PyRunnerError::Execution("pyodide.runPython is not a function".into())
1613            })?;
1614            let script_value = v8::String::new(scope, &script).ok_or_else(|| {
1615                PyRunnerError::Execution("failed to allocate execution script".into())
1616            })?;
1617            let result_value = run_fn
1618                .call(scope, pyodide.into(), &[script_value.into()])
1619                .ok_or_else(|| PyRunnerError::Execution("running entrypoint failed".into()))?;
1620            let json_str = result_value.to_rust_string_lossy(scope);
1621            let parsed: PythonCallResult = serde_json::from_str(&json_str).map_err(|err| {
1622                PyRunnerError::Execution(format!("failed to parse execution output: {err}"))
1623            })?;
1624            let mut execution: ExecutionOutput = parsed.into();
1625            let shared_buffers = collect_shared_buffers(scope, global)?;
1626            if !shared_buffers.is_empty() {
1627                let release_ids: Vec<String> = shared_buffers
1628                    .iter()
1629                    .map(|buffer| buffer.id.clone())
1630                    .collect();
1631                release_shared_buffers(scope, global, &release_ids)?;
1632            }
1633            execution.shared_buffers = shared_buffers;
1634            Ok(execution)
1635        })
1636    }
1637
1638    /// Executes an arbitrary Python snippet inside the active Pyodide context.
1639    pub fn run_python_snippet(&mut self, code: &str) -> Result<()> {
1640        let ctx_state = self.context_state.clone();
1641        self.with_context(|scope, _| {
1642            let pyodide = ctx_state
1643                .pyodide_local(scope)
1644                .ok_or_else(|| PyRunnerError::Execution("Pyodide is not loaded".into()))?;
1645            let run_key = v8::String::new(scope, "runPython").unwrap();
1646            let run_value = pyodide.get(scope, run_key.into()).ok_or_else(|| {
1647                PyRunnerError::Execution("pyodide.runPython is not available".into())
1648            })?;
1649            let run_fn = Local::<Function>::try_from(run_value).map_err(|_| {
1650                PyRunnerError::Execution("pyodide.runPython is not a function".into())
1651            })?;
1652            let script_value = v8::String::new(scope, code).ok_or_else(|| {
1653                PyRunnerError::Execution("failed to allocate python snippet".into())
1654            })?;
1655            run_fn
1656                .call(scope, pyodide.into(), &[script_value.into()])
1657                .ok_or_else(|| {
1658                    PyRunnerError::Execution("python snippet execution failed".into())
1659                })?;
1660            Ok(())
1661        })
1662    }
1663
1664    /// Compiles, instantiates, and evaluates an ES module sourced from the asset store.
1665    pub fn ensure_module(&mut self, specifier: &str) -> Result<()> {
1666        let specifier = normalize_specifier(specifier);
1667        if self.context_state.modules.borrow().contains_key(&specifier) {
1668            return Ok(());
1669        }
1670        let ctx_state = self.context_state.clone();
1671        self.with_context(|scope, _context| {
1672            let module = compile_module_from_assets(scope, &ctx_state, &specifier)?;
1673            instantiate_module(scope, &ctx_state, module)?;
1674            evaluate_module(scope, &ctx_state, module, &specifier)?;
1675            Ok(())
1676        })
1677    }
1678}
1679
1680fn populate_execution_output(
1681    runtime: &mut JsRuntime,
1682    value_global: v8::Global<v8::Value>,
1683    execution: &mut ExecutionOutput,
1684) -> Result<()> {
1685    runtime.with_context(|scope, _| {
1686        let value = v8::Local::new(scope, &value_global);
1687        if value.is_null_or_undefined() {
1688            execution.result = None;
1689            execution.json = None;
1690            return Ok(());
1691        }
1692
1693        if let Some(json_value) = v8::json::stringify(scope, value) {
1694            let json_str = json_value.to_rust_string_lossy(scope);
1695            if let Ok(parsed) = serde_json::from_str(&json_str) {
1696                execution.json = Some(parsed);
1697            }
1698        }
1699
1700        if let Some(string_value) = value.to_string(scope) {
1701            execution.result = Some(string_value.to_rust_string_lossy(scope));
1702        } else {
1703            execution.result = Some("<unprintable>".to_string());
1704        }
1705
1706        Ok(())
1707    })
1708}
1709
1710fn exec_script(
1711    scope: &mut PinScope<'_, '_>,
1712    name: &str,
1713    source: &str,
1714) -> std::result::Result<(), String> {
1715    let code = v8::String::new(scope, source).ok_or_else(|| "source too large".to_owned())?;
1716    let resource_name = v8::String::new(scope, name).ok_or_else(|| "name too long".to_owned())?;
1717    let origin = v8::ScriptOrigin::new(
1718        scope,
1719        resource_name.into(),
1720        0,
1721        0,
1722        false,
1723        0,
1724        None,
1725        false,
1726        false,
1727        false,
1728        None,
1729    );
1730    v8::tc_scope!(let try_catch, scope);
1731    let script = v8::Script::compile(try_catch, code, Some(&origin))
1732        .ok_or_else(|| format!("failed to compile {name}"))?;
1733    if script.run(try_catch).is_some() {
1734        Ok(())
1735    } else {
1736        let message = try_catch
1737            .exception()
1738            .and_then(|value| value.to_string(try_catch))
1739            .map(|s| s.to_rust_string_lossy(try_catch))
1740            .unwrap_or_else(|| "script execution failed".into());
1741        Err(message)
1742    }
1743}
1744
1745fn asset_fetch_callback(
1746    scope: &mut PinScope<'_, '_>,
1747    args: FunctionCallbackArguments<'_>,
1748    mut rv: ReturnValue<'_, Value>,
1749) {
1750    let name = if args.length() > 0 {
1751        args.get(0)
1752            .to_string(scope)
1753            .map(|s| s.to_rust_string_lossy(scope))
1754            .unwrap_or_default()
1755    } else {
1756        String::new()
1757    };
1758
1759    let Some(context_state) = scope.get_slot::<Rc<RuntimeContext>>() else {
1760        rv.set(v8::undefined(scope).into());
1761        return;
1762    };
1763
1764    let Some(asset) = context_state.assets.get(&name) else {
1765        debug!("asset request for '{name}' not found");
1766        rv.set(v8::undefined(scope).into());
1767        return;
1768    };
1769
1770    let result = v8::Object::new(scope);
1771    let kind_key = v8::String::new(scope, "kind").unwrap();
1772    let data_key = v8::String::new(scope, "data").unwrap();
1773    let size_key = v8::String::new(scope, "size").unwrap();
1774
1775    match asset {
1776        Asset::Text(text) => {
1777            let kind_value = v8::String::new(scope, "text").unwrap();
1778            let data_value = v8::String::new(scope, &text).unwrap();
1779            let size_value = v8::Number::new(scope, text.len() as f64);
1780            let _ = result.set(scope, kind_key.into(), kind_value.into());
1781            let _ = result.set(scope, data_key.into(), data_value.into());
1782            let _ = result.set(scope, size_key.into(), size_value.into());
1783        }
1784        Asset::Binary(bytes) => {
1785            let kind_value = v8::String::new(scope, "binary").unwrap();
1786            let vec = bytes.as_ref().to_vec();
1787            let backing = v8::ArrayBuffer::new_backing_store_from_vec(vec);
1788            let shared = backing.make_shared();
1789            let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &shared);
1790            let length = array_buffer.byte_length();
1791            let typed = Uint8Array::new(scope, array_buffer, 0, length).unwrap();
1792            let size_value = v8::Number::new(scope, length as f64);
1793            let _ = result.set(scope, kind_key.into(), kind_value.into());
1794            let _ = result.set(scope, data_key.into(), typed.into());
1795            let _ = result.set(scope, size_key.into(), size_value.into());
1796        }
1797    }
1798
1799    rv.set(result.into());
1800}
1801
1802fn record_buffer_event_callback(
1803    scope: &mut PinScope<'_, '_>,
1804    args: FunctionCallbackArguments<'_>,
1805    mut rv: ReturnValue<'_, Value>,
1806) {
1807    let event = args
1808        .get(0)
1809        .to_string(scope)
1810        .map(|s| s.to_rust_string_lossy(scope))
1811        .unwrap_or_default();
1812    if event.is_empty() {
1813        warn!(
1814            target = "aardvark::buffers",
1815            "shared buffer event missing name"
1816        );
1817        rv.set(v8::undefined(scope).into());
1818        return;
1819    }
1820
1821    let buffer_id = args
1822        .get(1)
1823        .to_string(scope)
1824        .map(|s| s.to_rust_string_lossy(scope))
1825        .unwrap_or_default();
1826    if buffer_id.is_empty() {
1827        warn!(
1828            target = "aardvark::buffers",
1829            buffers.event = event.as_str(),
1830            "shared buffer event missing id"
1831        );
1832        rv.set(v8::undefined(scope).into());
1833        return;
1834    }
1835
1836    let size = if args.length() > 2 {
1837        args.get(2).number_value(scope).unwrap_or(0.0).max(0.0)
1838    } else {
1839        0.0
1840    } as u64;
1841
1842    let mut metadata_json: Option<String> = None;
1843    if args.length() > 3 {
1844        let meta_value = args.get(3);
1845        if !meta_value.is_null_or_undefined() {
1846            if let Some(stringified) = v8::json::stringify(scope, meta_value) {
1847                metadata_json = Some(stringified.to_rust_string_lossy(scope));
1848            } else {
1849                warn!(
1850                    target = "aardvark::buffers",
1851                    buffers.event = event.as_str(),
1852                    buffers.id = buffer_id.as_str(),
1853                    "shared buffer metadata stringify failed"
1854                );
1855            }
1856        }
1857    }
1858
1859    info!(
1860        target = "aardvark::buffers",
1861        buffers.event = event.as_str(),
1862        buffers.id = buffer_id.as_str(),
1863        buffers.size = size,
1864        buffers.metadata = metadata_json.as_deref(),
1865        "shared buffer event"
1866    );
1867
1868    rv.set(v8::undefined(scope).into());
1869}
1870
1871fn filesystem_violation_callback(
1872    scope: &mut PinScope<'_, '_>,
1873    args: FunctionCallbackArguments<'_>,
1874    mut rv: ReturnValue<'_, Value>,
1875) {
1876    let message = args
1877        .get(0)
1878        .to_string(scope)
1879        .map(|s| s.to_rust_string_lossy(scope))
1880        .unwrap_or_else(|| "filesystem violation".to_string());
1881    let path_value = if args.length() > 1 {
1882        let value = args.get(1);
1883        if value.is_null_or_undefined() {
1884            None
1885        } else {
1886            value
1887                .to_string(scope)
1888                .map(|s| s.to_rust_string_lossy(scope))
1889        }
1890    } else {
1891        None
1892    };
1893
1894    if let Some(context_state) = scope.get_slot::<Rc<RuntimeContext>>() {
1895        context_state.record_filesystem_violation(path_value, message);
1896    } else {
1897        warn!(
1898            target = "aardvark::sandbox",
1899            "filesystem violation reported without runtime context"
1900        );
1901    }
1902
1903    rv.set(v8::undefined(scope).into());
1904}
1905
1906fn native_log_callback(
1907    scope: &mut PinScope<'_, '_>,
1908    args: FunctionCallbackArguments<'_>,
1909    mut rv: ReturnValue<'_, Value>,
1910) {
1911    let mut parts = Vec::with_capacity(args.length() as usize);
1912    for index in 0..args.length() {
1913        let value = args.get(index);
1914        if let Some(text) = value.to_string(scope) {
1915            parts.push(text.to_rust_string_lossy(scope));
1916        }
1917    }
1918
1919    let mut stream = ConsoleStream::Stdout;
1920    let mut start_index = 0;
1921    if let Some(first) = parts.first() {
1922        match first.as_str() {
1923            "__stderr__" => {
1924                stream = ConsoleStream::Stderr;
1925                start_index = 1;
1926            }
1927            "__stdout__" => {
1928                stream = ConsoleStream::Stdout;
1929                start_index = 1;
1930            }
1931            _ => {}
1932        }
1933    }
1934
1935    let message = if start_index >= parts.len() {
1936        String::new()
1937    } else {
1938        parts[start_index..].join(" ")
1939    };
1940
1941    if let Some(context_state) = scope.get_slot::<Rc<RuntimeContext>>() {
1942        match stream {
1943            ConsoleStream::Stdout => context_state.append_stdout(&message),
1944            ConsoleStream::Stderr => context_state.append_stderr(&message),
1945        }
1946    }
1947
1948    if !message.is_empty() {
1949        match stream {
1950            ConsoleStream::Stdout => {
1951                info!(target = "aardvark::js", "{}", message);
1952            }
1953            ConsoleStream::Stderr => {
1954                warn!(target = "aardvark::js", "{}", message);
1955            }
1956        }
1957    }
1958    rv.set(v8::undefined(scope).into());
1959}
1960
1961fn native_fetch_callback(
1962    scope: &mut PinScope<'_, '_>,
1963    args: FunctionCallbackArguments<'_>,
1964    mut rv: ReturnValue<'_, Value>,
1965) {
1966    let url = if args.length() > 0 {
1967        args.get(0)
1968            .to_string(scope)
1969            .map(|s| s.to_rust_string_lossy(scope))
1970            .unwrap_or_default()
1971    } else {
1972        String::new()
1973    };
1974
1975    if !(url.starts_with("http://") || url.starts_with("https://")) {
1976        rv.set(v8::undefined(scope).into());
1977        return;
1978    }
1979
1980    if let Some(local_path) = resolve_local_package_path(&url) {
1981        match fs::read(&local_path) {
1982            Ok(body) => {
1983                info!(
1984                    target = "aardvark::js",
1985                    %url,
1986                    path = %local_path.display(),
1987                    "serving package from local directory"
1988                );
1989                let backing = v8::ArrayBuffer::new_backing_store_from_vec(body);
1990                let byte_length = backing.len();
1991                let backing_shared = backing.make_shared();
1992                let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_shared);
1993                let uint8 = Uint8Array::new(scope, array_buffer, 0, byte_length)
1994                    .expect("failed to create typed array for local fetch");
1995
1996                let result = v8::Object::new(scope);
1997                let status_key = v8::String::new(scope, "status").unwrap();
1998                let status_value = v8::Integer::new(scope, 200);
1999                result.set(scope, status_key.into(), status_value.into());
2000
2001                let status_text_key = v8::String::new(scope, "statusText").unwrap();
2002                let status_text_value = v8::String::new(scope, "OK").unwrap();
2003                result.set(scope, status_text_key.into(), status_text_value.into());
2004
2005                let ok_key = v8::String::new(scope, "ok").unwrap();
2006                let ok_value = v8::Boolean::new(scope, true);
2007                result.set(scope, ok_key.into(), ok_value.into());
2008
2009                let url_key = v8::String::new(scope, "url").unwrap();
2010                let url_value = v8::String::new(scope, &url).unwrap();
2011                result.set(scope, url_key.into(), url_value.into());
2012
2013                let binary_key = v8::String::new(scope, "binary").unwrap();
2014                let binary_value = v8::Boolean::new(scope, true);
2015                result.set(scope, binary_key.into(), binary_value.into());
2016
2017                let body_key = v8::String::new(scope, "body").unwrap();
2018                result.set(scope, body_key.into(), uint8.into());
2019
2020                let headers_array = v8::Array::new(scope, 1);
2021                let header_pair = v8::Array::new(scope, 2);
2022                let name_value = v8::String::new(scope, "content-type").unwrap();
2023                let content_type = guess_content_type(&local_path);
2024                let value_value = v8::String::new(scope, content_type).unwrap();
2025                header_pair.set_index(scope, 0, name_value.into());
2026                header_pair.set_index(scope, 1, value_value.into());
2027                headers_array.set_index(scope, 0, header_pair.into());
2028                let headers_key = v8::String::new(scope, "headers").unwrap();
2029                result.set(scope, headers_key.into(), headers_array.into());
2030
2031                let content_type_key = v8::String::new(scope, "contentType").unwrap();
2032                let content_type_value = v8::String::new(scope, content_type).unwrap();
2033                result.set(scope, content_type_key.into(), content_type_value.into());
2034
2035                rv.set(result.into());
2036                return;
2037            }
2038            Err(err) => {
2039                tracing::warn!(
2040                    %url,
2041                    path = %local_path.display(),
2042                    error = ?err,
2043                    "failed to read local package asset"
2044                );
2045            }
2046        }
2047    }
2048
2049    let Some(context_state) = scope.get_slot::<Rc<RuntimeContext>>() else {
2050        rv.set(v8::undefined(scope).into());
2051        return;
2052    };
2053
2054    let parsed = match Url::parse(&url) {
2055        Ok(value) => value,
2056        Err(err) => {
2057            warn!(target = "aardvark::sandbox", %url, error = ?err, "network request rejected: invalid url");
2058            let message = v8::String::new(scope, "network access denied").unwrap();
2059            scope.throw_exception(message.into());
2060            return;
2061        }
2062    };
2063
2064    let host = match parsed.host_str() {
2065        Some(value) if !value.is_empty() => value.to_ascii_lowercase(),
2066        _ => {
2067            warn!(target = "aardvark::sandbox", %url, "network request rejected: missing host");
2068            let message = v8::String::new(scope, "network access denied").unwrap();
2069            scope.throw_exception(message.into());
2070            return;
2071        }
2072    };
2073    let port = parsed.port();
2074    let is_https = parsed.scheme().eq_ignore_ascii_case("https");
2075
2076    let decision = {
2077        let policy = context_state.network_policy.read();
2078        policy.evaluate(&host, port, is_https)
2079    };
2080
2081    if let NetworkDecision::Denied(reason) = decision {
2082        context_state.record_network_denial(&host, port, reason);
2083        let message_text = match reason {
2084            NetworkDenyReason::SchemeNotAllowed => {
2085                if let Some(p) = port {
2086                    format!("network access to '{}:{}' requires https", host, p)
2087                } else {
2088                    format!("network access to '{}' requires https", host)
2089                }
2090            }
2091            _ => {
2092                if let Some(p) = port {
2093                    format!("network access to '{}:{}' is not permitted", host, p)
2094                } else {
2095                    format!("network access to '{}' is not permitted", host)
2096                }
2097            }
2098        };
2099        warn!(
2100            target = "aardvark::sandbox",
2101            network.allowed = false,
2102            %url,
2103            host = host.as_str(),
2104            port,
2105            reason = ?reason,
2106            "network request blocked"
2107        );
2108        let message = v8::String::new(scope, &message_text).unwrap();
2109        scope.throw_exception(message.into());
2110        return;
2111    }
2112
2113    info!(
2114        target = "aardvark::sandbox",
2115        network.allowed = true,
2116        %url,
2117        host = host.as_str(),
2118        port,
2119        https = is_https,
2120        "network request allowed"
2121    );
2122
2123    context_state.record_network_contact(&host, port, is_https);
2124
2125    let response = match ureq::get(&url).call() {
2126        Ok(resp) => resp,
2127        Err(err) => {
2128            tracing::warn!(%url, error = ?err, "native fetch failed");
2129            rv.set(v8::undefined(scope).into());
2130            return;
2131        }
2132    };
2133
2134    let status = response.status();
2135    let status_text = response.status_text().to_string();
2136    let mut headers_list = Vec::new();
2137    for name in response.headers_names() {
2138        if let Some(value) = response.header(&name) {
2139            headers_list.push((name.to_ascii_lowercase(), value.to_string()));
2140        }
2141    }
2142
2143    let mut body = Vec::new();
2144    if let Err(err) = response.into_reader().read_to_end(&mut body) {
2145        tracing::warn!(%url, error = ?err, "native fetch read failed");
2146        rv.set(v8::undefined(scope).into());
2147        return;
2148    }
2149
2150    let is_binary = true;
2151    let backing = v8::ArrayBuffer::new_backing_store_from_vec(body);
2152    let byte_length = backing.len();
2153    let backing_shared = backing.make_shared();
2154    let array_buffer = v8::ArrayBuffer::with_backing_store(scope, &backing_shared);
2155    let uint8 = Uint8Array::new(scope, array_buffer, 0, byte_length)
2156        .expect("failed to create typed array for native fetch");
2157
2158    let result = v8::Object::new(scope);
2159    let status_key = v8::String::new(scope, "status").unwrap();
2160    let status_value = v8::Integer::new(scope, status as i32);
2161    result.set(scope, status_key.into(), status_value.into());
2162
2163    let status_text_key = v8::String::new(scope, "statusText").unwrap();
2164    let status_text_value = v8::String::new(scope, &status_text).unwrap();
2165    result.set(scope, status_text_key.into(), status_text_value.into());
2166
2167    let ok_key = v8::String::new(scope, "ok").unwrap();
2168    let ok_value = v8::Boolean::new(scope, (200..300).contains(&status));
2169    result.set(scope, ok_key.into(), ok_value.into());
2170
2171    let url_key = v8::String::new(scope, "url").unwrap();
2172    let url_value = v8::String::new(scope, &url).unwrap();
2173    result.set(scope, url_key.into(), url_value.into());
2174
2175    let binary_key = v8::String::new(scope, "binary").unwrap();
2176    let binary_value = v8::Boolean::new(scope, is_binary);
2177    result.set(scope, binary_key.into(), binary_value.into());
2178
2179    let body_key = v8::String::new(scope, "body").unwrap();
2180    result.set(scope, body_key.into(), uint8.into());
2181
2182    let headers_array = v8::Array::new(scope, headers_list.len() as i32);
2183    for (index, (name, value)) in headers_list.iter().enumerate() {
2184        let pair = v8::Array::new(scope, 2);
2185        let name_value = v8::String::new(scope, name).unwrap();
2186        let value_value = v8::String::new(scope, value).unwrap();
2187        pair.set_index(scope, 0, name_value.into());
2188        pair.set_index(scope, 1, value_value.into());
2189        headers_array.set_index(scope, index as u32, pair.into());
2190    }
2191    let headers_key = v8::String::new(scope, "headers").unwrap();
2192    result.set(scope, headers_key.into(), headers_array.into());
2193
2194    // Provide a hint for the content type.
2195    if let Some((_, value)) = headers_list
2196        .iter()
2197        .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
2198    {
2199        let content_type_key = v8::String::new(scope, "contentType").unwrap();
2200        let content_type_value = v8::String::new(scope, value).unwrap();
2201        result.set(scope, content_type_key.into(), content_type_value.into());
2202    }
2203
2204    rv.set(result.into());
2205}
2206
2207fn compile_module_from_assets<'a>(
2208    scope: &mut PinScope<'a, '_>,
2209    ctx: &Rc<RuntimeContext>,
2210    specifier: &str,
2211) -> Result<Local<'a, Module>> {
2212    if let Some(existing) = ctx.modules.borrow().get(specifier) {
2213        return Ok(Local::new(scope, existing));
2214    }
2215    let asset = ctx
2216        .assets
2217        .get(specifier)
2218        .ok_or_else(|| PyRunnerError::Execution(format!("module asset not found: {specifier}")))?;
2219    let source_text = match asset {
2220        Asset::Text(text) => text,
2221        Asset::Binary(_) => {
2222            return Err(PyRunnerError::Execution(format!(
2223                "module asset '{specifier}' is binary"
2224            )))
2225        }
2226    };
2227    let source_str: &str = &source_text;
2228    let source_string = v8::String::new(scope, source_str).ok_or_else(|| {
2229        PyRunnerError::Execution(format!("failed to allocate source string for {specifier}"))
2230    })?;
2231    let resource_name = v8::String::new(scope, specifier).ok_or_else(|| {
2232        PyRunnerError::Execution(format!("failed to allocate resource name for {specifier}"))
2233    })?;
2234    let origin = v8::ScriptOrigin::new(
2235        scope,
2236        resource_name.into(),
2237        0,
2238        0,
2239        false,
2240        0,
2241        None,
2242        false,
2243        false,
2244        true,
2245        None,
2246    );
2247    let mut source = script_compiler::Source::new(source_string, Some(&origin));
2248    let module = script_compiler::compile_module(scope, &mut source)
2249        .ok_or_else(|| PyRunnerError::Execution(format!("failed to compile module {specifier}")))?;
2250    let global = v8::Global::new(scope, module);
2251    ctx.modules
2252        .borrow_mut()
2253        .insert(specifier.to_owned(), global);
2254    ctx.module_by_hash
2255        .borrow_mut()
2256        .insert(module.get_identity_hash().get(), specifier.to_owned());
2257    // Recursively compile dependencies so that instantiate_module can simply look them up.
2258    let requests = module.get_module_requests();
2259    let len = requests.length();
2260    for i in 0..len {
2261        if let Some(data) = requests.get(scope, i) {
2262            if let Ok(request) = Local::<ModuleRequest>::try_from(data) {
2263                let request_spec = request.get_specifier().to_rust_string_lossy(scope);
2264                let resolved = resolve_specifier(specifier, &request_spec);
2265                compile_module_from_assets(scope, ctx, &resolved)?;
2266            }
2267        }
2268    }
2269
2270    Ok(module)
2271}
2272
2273fn instantiate_module(
2274    scope: &mut PinScope<'_, '_>,
2275    ctx: &Rc<RuntimeContext>,
2276    module: Local<Module>,
2277) -> Result<()> {
2278    let scope_ptr = scope as *mut _ as *mut std::ffi::c_void;
2279    TLS_RUNTIME_CONTEXT.with(|cell| cell.set(Rc::as_ptr(ctx)));
2280    TLS_SCOPE.with(|cell| cell.set(scope_ptr));
2281    let instantiated = module.instantiate_module(scope, resolve_module_callback);
2282    TLS_SCOPE.with(|cell| cell.set(std::ptr::null_mut()));
2283    TLS_RUNTIME_CONTEXT.with(|cell| cell.set(std::ptr::null()));
2284    if instantiated.is_some() {
2285        Ok(())
2286    } else {
2287        Err(PyRunnerError::Execution(
2288            "failed to instantiate module".into(),
2289        ))
2290    }
2291}
2292
2293fn evaluate_module(
2294    scope: &mut PinScope<'_, '_>,
2295    ctx: &Rc<RuntimeContext>,
2296    module: Local<Module>,
2297    specifier: &str,
2298) -> Result<()> {
2299    module
2300        .evaluate(scope)
2301        .ok_or_else(|| PyRunnerError::Execution("module evaluation failed".into()))?;
2302    let namespace_value = module.get_module_namespace();
2303    let namespace_obj = v8::Local::<Object>::try_from(namespace_value).map_err(|_| {
2304        PyRunnerError::Execution(format!(
2305            "module namespace for {specifier} was not an object"
2306        ))
2307    })?;
2308    ctx.module_namespaces
2309        .borrow_mut()
2310        .insert(specifier.to_owned(), v8::Global::new(scope, namespace_obj));
2311    Ok(())
2312}
2313
2314fn resolve_module_callback<'a>(
2315    _context: Local<'a, Context>,
2316    specifier: Local<'a, V8String>,
2317    _import_assertions: Local<'a, FixedArray>,
2318    referencing_module: Local<'a, Module>,
2319) -> Option<Local<'a, Module>> {
2320    let scope_ptr = TLS_SCOPE.with(|cell| cell.get()) as *mut PinScope<'static, 'static>;
2321    let ctx_ptr = TLS_RUNTIME_CONTEXT.with(|cell| cell.get());
2322    if scope_ptr.is_null() || ctx_ptr.is_null() {
2323        return None;
2324    }
2325    let scope_ref = unsafe { &mut *scope_ptr };
2326    let ctx_ref = unsafe { &*ctx_ptr };
2327    let request = specifier.to_rust_string_lossy(scope_ref);
2328    let parent_hash = referencing_module.get_identity_hash().get();
2329    let base = ctx_ref
2330        .module_by_hash
2331        .borrow()
2332        .get(&parent_hash)
2333        .cloned()
2334        .unwrap_or_default();
2335    let resolved = resolve_specifier(&base, &request);
2336    let maybe_module = ctx_ref
2337        .modules
2338        .borrow()
2339        .get(&resolved)
2340        .cloned()
2341        .map(|global| Local::new(scope_ref, &global));
2342    if let Some(module) = maybe_module {
2343        Some(module)
2344    } else {
2345        if let Some(message) = v8::String::new(
2346            scope_ref,
2347            &format!("unresolved module specifier: {resolved}"),
2348        ) {
2349            scope_ref.throw_exception(message.into());
2350        }
2351        None
2352    }
2353}
2354
2355fn resolve_specifier(base: &str, request: &str) -> String {
2356    if request.starts_with("./") || request.starts_with("../") {
2357        let mut parts: Vec<&str> = if base.is_empty() {
2358            Vec::new()
2359        } else {
2360            base.rsplit_once('/')
2361                .map(|(prefix, _)| prefix.split('/').collect())
2362                .unwrap_or_default()
2363        };
2364        for segment in request.split('/') {
2365            match segment {
2366                "." | "" => {}
2367                ".." => {
2368                    parts.pop();
2369                }
2370                other => parts.push(other),
2371            }
2372        }
2373        return parts.join("/");
2374    }
2375    request.trim_start_matches("./").to_owned()
2376}
2377
2378fn normalize_specifier(spec: &str) -> String {
2379    if spec.starts_with("./") {
2380        spec.trim_start_matches("./").to_owned()
2381    } else {
2382        spec.to_owned()
2383    }
2384}
2385
2386impl RuntimeContext {
2387    fn new() -> Self {
2388        Self {
2389            assets: AssetStore::new(),
2390            modules: RefCell::new(HashMap::new()),
2391            module_by_hash: RefCell::new(HashMap::new()),
2392            module_namespaces: RefCell::new(HashMap::new()),
2393            pyodide_instance: RefCell::new(None),
2394            stdout_log: RefCell::new(String::new()),
2395            stderr_log: RefCell::new(String::new()),
2396            network_policy: RwLock::new(NetworkPolicy::default()),
2397            network_contacts: RwLock::new(Vec::new()),
2398            network_denied: RwLock::new(Vec::new()),
2399            filesystem_violations: RwLock::new(Vec::new()),
2400        }
2401    }
2402
2403    fn clear_console(&self) {
2404        self.stdout_log.borrow_mut().clear();
2405        self.stderr_log.borrow_mut().clear();
2406    }
2407
2408    fn append_stdout(&self, message: &str) {
2409        if message.is_empty() {
2410            return;
2411        }
2412        let mut stdout = self.stdout_log.borrow_mut();
2413        stdout.push_str(message);
2414        stdout.push('\n');
2415    }
2416
2417    fn append_stderr(&self, message: &str) {
2418        if message.is_empty() {
2419            return;
2420        }
2421        let mut stderr = self.stderr_log.borrow_mut();
2422        stderr.push_str(message);
2423        stderr.push('\n');
2424    }
2425
2426    fn take_stdout(&self) -> String {
2427        let mut stdout = self.stdout_log.borrow_mut();
2428        std::mem::take(&mut *stdout)
2429    }
2430
2431    fn take_stderr(&self) -> String {
2432        let mut stderr = self.stderr_log.borrow_mut();
2433        std::mem::take(&mut *stderr)
2434    }
2435
2436    fn module_namespace<'a>(
2437        &self,
2438        scope: &mut PinScope<'a, '_>,
2439        specifier: &str,
2440    ) -> Option<Local<'a, Object>> {
2441        self.module_namespaces
2442            .borrow()
2443            .get(specifier)
2444            .map(|global| Local::new(scope, global))
2445    }
2446
2447    fn pyodide_local<'a>(&self, scope: &mut PinScope<'a, '_>) -> Option<Local<'a, Object>> {
2448        self.pyodide_instance
2449            .borrow()
2450            .as_ref()
2451            .map(|global| Local::new(scope, global))
2452    }
2453
2454    fn set_network_policy(&self, allow: &[String], https_only: bool) {
2455        let mut policy = self.network_policy.write();
2456        *policy = NetworkPolicy::new(allow, https_only);
2457        self.clear_network_contacts();
2458        self.clear_network_denied();
2459    }
2460
2461    fn clear_network_contacts(&self) {
2462        self.network_contacts.write().clear();
2463    }
2464
2465    fn clear_network_denied(&self) {
2466        self.network_denied.write().clear();
2467    }
2468
2469    fn record_network_contact(&self, host: &str, port: Option<u16>, https: bool) {
2470        let mut contacts = self.network_contacts.write();
2471        if !contacts
2472            .iter()
2473            .any(|entry| entry.host == host && entry.port == port && entry.https == https)
2474        {
2475            contacts.push(NetworkContactRecord {
2476                host: host.to_owned(),
2477                port,
2478                https,
2479            });
2480        }
2481    }
2482
2483    fn take_network_contacts(&self) -> Vec<NetworkContactRecord> {
2484        let mut contacts = self.network_contacts.write();
2485        std::mem::take(&mut *contacts)
2486    }
2487
2488    fn record_network_denial(&self, host: &str, port: Option<u16>, reason: NetworkDenyReason) {
2489        let reason_str = reason.as_str().to_string();
2490        let https_required = matches!(reason, NetworkDenyReason::SchemeNotAllowed);
2491        let mut denied = self.network_denied.write();
2492        if !denied.iter().any(|entry| {
2493            entry.host == host
2494                && entry.port == port
2495                && entry.reason == reason_str
2496                && entry.https_required == https_required
2497        }) {
2498            denied.push(NetworkDeniedRecord {
2499                host: host.to_owned(),
2500                port,
2501                reason: reason_str,
2502                https_required,
2503            });
2504        }
2505    }
2506
2507    fn take_network_denied(&self) -> Vec<NetworkDeniedRecord> {
2508        let mut denied = self.network_denied.write();
2509        std::mem::take(&mut *denied)
2510    }
2511
2512    fn record_filesystem_violation(&self, path: Option<String>, message: String) {
2513        let mut violations = self.filesystem_violations.write();
2514        violations.push(FilesystemViolationRecord { path, message });
2515    }
2516
2517    fn clear_filesystem_violations(&self) {
2518        self.filesystem_violations.write().clear();
2519    }
2520
2521    fn take_filesystem_violations(&self) -> Vec<FilesystemViolationRecord> {
2522        let mut violations = self.filesystem_violations.write();
2523        std::mem::take(&mut *violations)
2524    }
2525}
2526
2527#[derive(Debug, Deserialize)]
2528struct PythonCallResult {
2529    stdout: String,
2530    stderr: String,
2531    result: Option<String>,
2532    #[serde(default)]
2533    exception_type: Option<String>,
2534    #[serde(default)]
2535    exception_value: Option<String>,
2536    #[serde(default)]
2537    traceback: Option<String>,
2538}
2539
2540#[derive(Debug, Clone)]
2541pub struct SharedBuffer {
2542    pub id: String,
2543    pub length: usize,
2544    pub metadata: Option<JsonValue>,
2545    pub backing: Option<Arc<SharedBufferBacking>>,
2546    pub bytes: Option<Bytes>,
2547}
2548
2549#[derive(Debug)]
2550pub struct SharedBufferBacking {
2551    store: v8::SharedRef<v8::BackingStore>,
2552    offset: usize,
2553    length: usize,
2554}
2555
2556impl SharedBufferBacking {
2557    fn new(store: v8::SharedRef<v8::BackingStore>, offset: usize, length: usize) -> Self {
2558        Self {
2559            store,
2560            offset,
2561            length,
2562        }
2563    }
2564
2565    pub(crate) fn as_slice(&self) -> &[u8] {
2566        if self.length == 0 {
2567            return &[];
2568        }
2569        let Some(ptr) = self.store.data() else {
2570            return &[];
2571        };
2572        let store_size = self.store.byte_length();
2573        if self.offset > store_size || self.length > store_size {
2574            return &[];
2575        }
2576        if let Some(end) = self.offset.checked_add(self.length) {
2577            if end > store_size {
2578                return &[];
2579            }
2580        } else {
2581            return &[];
2582        }
2583        unsafe {
2584            let data = ptr.as_ptr().add(self.offset) as *const u8;
2585            std::slice::from_raw_parts(data, self.length)
2586        }
2587    }
2588}
2589
2590#[derive(Debug, Clone)]
2591pub struct ExecutionOutput {
2592    pub stdout: String,
2593    pub stderr: String,
2594    pub result: Option<String>,
2595    pub exception_type: Option<String>,
2596    pub exception_value: Option<String>,
2597    pub traceback: Option<String>,
2598    pub json: Option<JsonValue>,
2599    pub shared_buffers: Vec<SharedBuffer>,
2600}
2601
2602impl From<PythonCallResult> for ExecutionOutput {
2603    fn from(value: PythonCallResult) -> Self {
2604        Self {
2605            stdout: value.stdout,
2606            stderr: value.stderr,
2607            result: value.result,
2608            exception_type: value.exception_type,
2609            exception_value: value.exception_value,
2610            traceback: value.traceback,
2611            json: None,
2612            shared_buffers: Vec::new(),
2613        }
2614    }
2615}
2616
2617fn collect_shared_buffers<'a>(
2618    scope: &mut PinScope<'a, '_>,
2619    global: Local<'a, Object>,
2620) -> Result<Vec<SharedBuffer>> {
2621    let mut buffers = Vec::new();
2622    let collect_key = v8::String::new(scope, "__aardvarkCollectSharedBuffers").unwrap();
2623    let Some(collect_value) = global.get(scope, collect_key.into()) else {
2624        return Ok(buffers);
2625    };
2626    let Ok(collect_fn) = Local::<Function>::try_from(collect_value) else {
2627        return Ok(buffers);
2628    };
2629    let result = collect_fn
2630        .call(scope, global.into(), &[])
2631        .ok_or_else(|| PyRunnerError::Execution("collect shared buffers call failed".into()))?;
2632    let Ok(array) = Local::<Array>::try_from(result) else {
2633        return Ok(buffers);
2634    };
2635    let length = array.length();
2636    if length == 0 {
2637        return Ok(buffers);
2638    }
2639
2640    let id_key = v8::String::new(scope, "id").unwrap();
2641    let buffer_key = v8::String::new(scope, "buffer").unwrap();
2642    let metadata_key = v8::String::new(scope, "metadata").unwrap();
2643
2644    for index in 0..length {
2645        let entry_value = array
2646            .get_index(scope, index)
2647            .ok_or_else(|| PyRunnerError::Execution("shared buffer entry missing".into()))?;
2648        let entry_obj = entry_value
2649            .to_object(scope)
2650            .ok_or_else(|| PyRunnerError::Execution("shared buffer entry not an object".into()))?;
2651        let id_value = entry_obj
2652            .get(scope, id_key.into())
2653            .ok_or_else(|| PyRunnerError::Execution("shared buffer missing id".into()))?;
2654        let id = id_value
2655            .to_string(scope)
2656            .ok_or_else(|| PyRunnerError::Execution("failed to stringify buffer id".into()))?
2657            .to_rust_string_lossy(scope);
2658
2659        let buffer_value = entry_obj
2660            .get(scope, buffer_key.into())
2661            .ok_or_else(|| PyRunnerError::Execution("shared buffer missing payload".into()))?;
2662        let typed_array = Local::<Uint8Array>::try_from(buffer_value).map_err(|_| {
2663            PyRunnerError::Execution("shared buffer payload is not a Uint8Array".into())
2664        })?;
2665        let byte_len = typed_array.byte_length();
2666        let array_buffer = typed_array.buffer(scope).ok_or_else(|| {
2667            PyRunnerError::Execution("shared buffer missing backing store".into())
2668        })?;
2669        let backing_store = array_buffer.get_backing_store();
2670        let offset = typed_array.byte_offset();
2671
2672        let metadata = match entry_obj.get(scope, metadata_key.into()) {
2673            Some(value) if !value.is_null_or_undefined() => {
2674                let json_value = v8::json::stringify(scope, value).ok_or_else(|| {
2675                    PyRunnerError::Execution("failed to stringify shared buffer metadata".into())
2676                })?;
2677                let json_str = json_value.to_rust_string_lossy(scope);
2678                Some(serde_json::from_str(&json_str).map_err(|err| {
2679                    PyRunnerError::Execution(format!(
2680                        "failed to parse shared buffer metadata: {err}"
2681                    ))
2682                })?)
2683            }
2684            _ => None,
2685        };
2686
2687        buffers.push(SharedBuffer {
2688            id,
2689            length: byte_len,
2690            metadata,
2691            backing: Some(Arc::new(SharedBufferBacking::new(
2692                backing_store,
2693                offset,
2694                byte_len,
2695            ))),
2696            bytes: None,
2697        });
2698    }
2699
2700    Ok(buffers)
2701}
2702
2703fn release_shared_buffers<'a>(
2704    scope: &mut PinScope<'a, '_>,
2705    global: Local<'a, Object>,
2706    ids: &[String],
2707) -> Result<()> {
2708    let release_key = v8::String::new(scope, "__aardvarkReleaseSharedBuffers").unwrap();
2709    let Some(release_value) = global.get(scope, release_key.into()) else {
2710        return Ok(());
2711    };
2712    let Ok(release_fn) = Local::<Function>::try_from(release_value) else {
2713        return Ok(());
2714    };
2715
2716    let mut args: Vec<Local<Value>> = Vec::new();
2717    if !ids.is_empty() {
2718        let id_array = Array::new(scope, ids.len() as i32);
2719        for (index, id) in ids.iter().enumerate() {
2720            let id_value = v8::String::new(scope, id).ok_or_else(|| {
2721                PyRunnerError::Execution("failed to allocate buffer id string".into())
2722            })?;
2723            id_array.set_index(scope, index as u32, id_value.into());
2724        }
2725        args.push(id_array.into());
2726    }
2727
2728    release_fn
2729        .call(scope, global.into(), &args)
2730        .ok_or_else(|| PyRunnerError::Execution("release shared buffers call failed".into()))?;
2731    Ok(())
2732}
2733
2734#[cfg(test)]
2735mod tests {
2736    use super::*;
2737    use std::ffi::OsString;
2738    use std::fs;
2739    use tempfile::tempdir;
2740
2741    struct EnvGuard {
2742        original: Option<OsString>,
2743    }
2744
2745    impl EnvGuard {
2746        fn clear() -> Self {
2747            let original = env::var_os("AARDVARK_PYODIDE_PACKAGE_DIR");
2748            env::remove_var("AARDVARK_PYODIDE_PACKAGE_DIR");
2749            Self { original }
2750        }
2751    }
2752
2753    impl Drop for EnvGuard {
2754        fn drop(&mut self) {
2755            match self.original.take() {
2756                Some(value) => env::set_var("AARDVARK_PYODIDE_PACKAGE_DIR", value),
2757                None => env::remove_var("AARDVARK_PYODIDE_PACKAGE_DIR"),
2758            }
2759        }
2760    }
2761
2762    #[test]
2763    fn package_root_override_takes_precedence() {
2764        reset_package_root_for_tests();
2765        let temp = tempdir().expect("create tempdir");
2766        let cache_dir = temp.path().join("cache");
2767        fs::create_dir(&cache_dir).expect("create cache dir");
2768
2769        let guard = EnvGuard::clear();
2770        set_package_root_override(Some(cache_dir.clone()));
2771        let resolved = package_root_dir().expect("package root available");
2772        assert_eq!(resolved, cache_dir);
2773
2774        drop(guard);
2775        reset_package_root_for_tests();
2776    }
2777
2778    #[test]
2779    fn package_root_falls_back_to_env() {
2780        reset_package_root_for_tests();
2781        let temp = tempdir().expect("create tempdir");
2782        let cache_dir = temp.path().join("env-cache");
2783        fs::create_dir(&cache_dir).expect("create env cache dir");
2784
2785        std::env::set_var("AARDVARK_PYODIDE_PACKAGE_DIR", &cache_dir);
2786        let resolved = package_root_dir().expect("package root from env");
2787        assert_eq!(resolved, cache_dir);
2788
2789        std::env::remove_var("AARDVARK_PYODIDE_PACKAGE_DIR");
2790        reset_package_root_for_tests();
2791    }
2792}