Skip to main content

eryx_runtime/
preinit.rs

1//! Pre-initialization support for linked Python components.
2//!
3//! This module provides functionality to pre-initialize Python components
4//! after linking. Pre-initialization runs the Python interpreter's startup
5//! code and captures the initialized memory state into the component, avoiding
6//! the initialization cost at runtime.
7//!
8//! # How It Works
9//!
10//! 1. We link the component with real WASI imports
11//! 2. We use `wasmtime-wizer` to instrument the component (adding state accessors)
12//! 3. We instantiate the instrumented component - Python initializes
13//! 4. Optionally run imports (e.g., `import numpy`) to capture more state
14//! 5. Call `finalize-preinit` to reset WASI file handle state
15//! 6. The memory state is captured and embedded into the original component
16//! 7. The resulting component starts with Python already initialized
17//!
18//! # Performance Impact
19//!
20//! - First build with pre-init: ~3-4 seconds (one-time cost)
21//! - Per-execution after pre-init: ~1-5ms (vs ~450-500ms without)
22//!
23//! # Example
24//!
25//! ```rust,ignore
26//! use eryx_runtime::preinit::{PreInitOptions, pre_initialize_with_options};
27//!
28//! // Pre-initialize with native extensions
29//! let preinit_component = pre_initialize_with_options(
30//!     PreInitOptions::new(&python_stdlib_path)
31//!         .site_packages(&site_packages_path)
32//!         .imports(["numpy", "pandas"])  // Modules to import during pre-init
33//!         .extensions(native_extensions)
34//!         .setup_code("import numpy as np; arr = np.zeros(10)"),  // Optional setup code
35//! ).await?;
36//! ```
37
38use anyhow::{Result, anyhow};
39use std::collections::HashSet;
40use std::path::{Path, PathBuf};
41use tempfile::TempDir;
42use wasmtime::{
43    Config, Engine, Store,
44    component::{Component, Func, Instance, Linker, ResourceTable, Val},
45};
46use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
47use wasmtime_wizer::{WasmtimeWizerComponent, Wizer};
48
49use crate::linker::{NativeExtension, link_with_extensions};
50
51/// A callback the sandbox will offer at runtime, as the guest sees it.
52///
53/// Passing the declarations of the callbacks a sandbox will register to
54/// [`PreInitOptions::callbacks`] installs their Python wrappers during
55/// pre-initialization, so a fresh instance whose host registers the same set
56/// (compared by name, description and schema) skips the per-instance setup
57/// entirely. The set is matched irrespective of order.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct CallbackDeclaration {
60    /// Unique name of the callback (e.g. `"http.get"`).
61    pub name: String,
62    /// Human-readable description.
63    pub description: String,
64    /// JSON Schema for the callback's arguments, serialized.
65    pub parameters_schema_json: String,
66}
67
68/// Everything [`pre_initialize_with_options`] needs.
69#[derive(Debug, Clone)]
70pub struct PreInitOptions {
71    python_stdlib: PathBuf,
72    site_packages: Option<PathBuf>,
73    imports: Vec<String>,
74    extensions: Vec<NativeExtension>,
75    setup_code: Option<String>,
76    callbacks: Vec<CallbackDeclaration>,
77}
78
79impl PreInitOptions {
80    /// Options for pre-initializing with the Python standard library at
81    /// `python_stdlib` and nothing else.
82    pub fn new(python_stdlib: impl Into<PathBuf>) -> Self {
83        Self {
84            python_stdlib: python_stdlib.into(),
85            site_packages: None,
86            imports: Vec::new(),
87            extensions: Vec::new(),
88            setup_code: None,
89            callbacks: Vec::new(),
90        }
91    }
92
93    /// Mount `path` as `/site-packages` during pre-initialization.
94    #[must_use]
95    pub fn site_packages(mut self, path: impl Into<PathBuf>) -> Self {
96        self.site_packages = Some(path.into());
97        self
98    }
99
100    /// Modules to import during pre-init (e.g. `["numpy", "pandas"]`).
101    #[must_use]
102    pub fn imports<I, S>(mut self, imports: I) -> Self
103    where
104        I: IntoIterator<Item = S>,
105        S: Into<String>,
106    {
107        self.imports = imports.into_iter().map(Into::into).collect();
108        self
109    }
110
111    /// Native extensions to link into the component.
112    #[must_use]
113    pub fn extensions(mut self, extensions: Vec<NativeExtension>) -> Self {
114        self.extensions = extensions;
115        self
116    }
117
118    /// Python code to run after the imports, baked into the snapshot. Use
119    /// this to pre-create objects (e.g. a Jinja2 `SandboxedEnvironment`) so
120    /// every sandbox starts with them in copy-on-write memory.
121    #[must_use]
122    pub fn setup_code(mut self, code: impl Into<String>) -> Self {
123        self.setup_code = Some(code.into());
124        self
125    }
126
127    /// Callbacks the sandboxes will register at runtime; see
128    /// [`CallbackDeclaration`]. Setup code cannot invoke them (there is no
129    /// host to answer during pre-initialization).
130    #[must_use]
131    pub fn callbacks(mut self, callbacks: Vec<CallbackDeclaration>) -> Self {
132        self.callbacks = callbacks;
133        self
134    }
135}
136
137/// Context for the pre-initialization runtime.
138struct PreInitCtx {
139    wasi: WasiCtx,
140    table: ResourceTable,
141    /// Temp directory for dummy files - must be kept alive during pre-init
142    #[allow(dead_code)]
143    temp_dir: Option<TempDir>,
144    /// What the `list-callbacks` import answers, sorted by name.
145    callbacks: Vec<CallbackDeclaration>,
146}
147
148impl std::fmt::Debug for PreInitCtx {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("PreInitCtx").finish_non_exhaustive()
151    }
152}
153
154impl WasiView for PreInitCtx {
155    fn ctx(&mut self) -> WasiCtxView<'_> {
156        WasiCtxView {
157            ctx: &mut self.wasi,
158            table: &mut self.table,
159        }
160    }
161}
162
163/// Pre-initialize a Python component with native extensions.
164///
165/// This function links the component with native extensions, runs the Python
166/// interpreter's initialization, optionally imports modules, and captures the
167/// initialized memory state into the returned component.
168///
169/// # Arguments
170///
171/// * `python_stdlib` - Path to Python standard library directory
172/// * `site_packages` - Optional path to site-packages directory
173/// * `imports` - Modules to import during pre-init (e.g., ["numpy", "pandas"])
174/// * `extensions` - Native extensions to link into the component
175/// * `setup_code` - Optional Python code to execute after imports, baked into
176///   the snapshot. Use this to pre-create objects (e.g., a Jinja2
177///   `SandboxedEnvironment`) so every sandbox starts with them in COW memory.
178///
179/// # Returns
180///
181/// The pre-initialized component bytes, ready for instantiation.
182///
183/// # Errors
184///
185/// Returns an error if pre-initialization fails (e.g., Python init error,
186/// import failure, or setup code exception).
187pub async fn pre_initialize(
188    python_stdlib: &Path,
189    site_packages: Option<&Path>,
190    imports: &[&str],
191    extensions: &[NativeExtension],
192    setup_code: Option<&str>,
193) -> Result<Vec<u8>> {
194    let mut options = PreInitOptions::new(python_stdlib)
195        .imports(imports.iter().copied())
196        .extensions(extensions.to_vec());
197    if let Some(path) = site_packages {
198        options = options.site_packages(path);
199    }
200    if let Some(code) = setup_code {
201        options = options.setup_code(code);
202    }
203    pre_initialize_with_options(options).await
204}
205
206/// Pre-initialize a Python component according to `options`.
207///
208/// Links the component with the native extensions, runs the Python
209/// interpreter's initialization, imports the requested modules, runs the setup
210/// code, installs the declared callbacks' wrappers, and captures the resulting
211/// memory state into the returned component.
212///
213/// # Errors
214///
215/// Returns an error if pre-initialization fails (e.g., Python init error,
216/// import failure, or setup code exception).
217pub async fn pre_initialize_with_options(options: PreInitOptions) -> Result<Vec<u8>> {
218    let PreInitOptions {
219        python_stdlib,
220        site_packages,
221        imports,
222        extensions,
223        setup_code,
224        mut callbacks,
225    } = options;
226    let python_stdlib = python_stdlib.as_path();
227    let site_packages = site_packages.as_deref();
228    // The guest compares the host's declarations with the installed set as a
229    // serialized list, so both sides present them in the same (name) order.
230    callbacks.sort_by(|a, b| a.name.cmp(&b.name));
231
232    // Link the component with real WASI adapter.
233    let original_component = link_with_extensions(&extensions)
234        .map_err(|e| anyhow!("Failed to link component with extensions: {}", e))?;
235
236    // Phase 1: Instrument the component (synchronous).
237    // This adds state accessor exports that wasmtime-wizer uses to read
238    // memory/global state for the snapshot.
239    let wizer = Wizer::new();
240    let (cx, instrumented_wasm) = wizer
241        .instrument_component(&original_component)
242        .map_err(|e| e.context("Failed to instrument component"))?;
243
244    // Phase 2: Instantiate and run the instrumented component.
245    let mut config = Config::new();
246    config.wasm_component_model(true);
247    config.wasm_component_model_async(true);
248
249    let engine = Engine::new(&config)?;
250    let component = Component::new(&engine, &instrumented_wasm)?;
251
252    // Set up WASI context with Python paths
253    let table = ResourceTable::new();
254
255    // Build PYTHONPATH from stdlib and site-packages
256    let mut python_path_parts = vec!["/python-stdlib".to_string()];
257    if site_packages.is_some() {
258        python_path_parts.push("/site-packages".to_string());
259    }
260    let python_path = python_path_parts.join(":");
261
262    let mut wasi_builder = WasiCtxBuilder::new();
263    wasi_builder
264        .env("PYTHONHOME", "/python-stdlib")
265        .env("PYTHONPATH", &python_path)
266        .env("PYTHONUNBUFFERED", "1");
267
268    // Mount Python stdlib
269    if python_stdlib.exists() {
270        wasi_builder.preopened_dir(python_stdlib, "python-stdlib", FsPerms::ReadOnly)?;
271    } else {
272        return Err(anyhow!(
273            "Python stdlib not found at {}",
274            python_stdlib.display()
275        ));
276    }
277
278    // Mount site-packages if provided
279    let temp_dir = if let Some(site_pkg) = site_packages {
280        if site_pkg.exists() {
281            wasi_builder.preopened_dir(site_pkg, "site-packages", FsPerms::ReadOnly)?;
282        }
283        None
284    } else {
285        // Create empty temp dir for site-packages to avoid errors
286        let temp = TempDir::new()?;
287        wasi_builder.preopened_dir(temp.path(), "site-packages", FsPerms::ReadOnly)?;
288        Some(temp)
289    };
290
291    let wasi = wasi_builder.build();
292
293    let has_callbacks = !callbacks.is_empty();
294    let mut store = Store::new(
295        &engine,
296        PreInitCtx {
297            wasi,
298            table,
299            temp_dir,
300            callbacks,
301        },
302    );
303
304    // Create linker and add WASI
305    let mut linker = Linker::new(&engine);
306    wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
307
308    // Add stub implementations for the sandbox imports
309    // These are needed during pre-init but won't be called
310    add_sandbox_stubs(&mut linker)?;
311
312    // Instantiate the component
313    // This triggers Python initialization via wit-dylib's Interpreter::initialize()
314    let instance = linker.instantiate_async(&mut store, &component).await?;
315
316    // If imports are specified, call execute() to import them
317    if !imports.is_empty() {
318        call_execute_for_imports(&mut store, &instance, &imports).await?;
319    }
320
321    // If setup code is provided, execute it after imports so its state
322    // (variables, objects, etc.) gets captured in the Wizer snapshot.
323    if let Some(code) = &setup_code {
324        call_execute_code(&mut store, &instance, code, "setup code").await?;
325    }
326
327    // The guest installs the declared callbacks' wrappers on its first
328    // execute(). If nothing above ran one, run a no-op so the installation
329    // still lands in the snapshot.
330    if has_callbacks && imports.is_empty() && setup_code.is_none() {
331        call_execute_code(&mut store, &instance, "pass", "callback installation").await?;
332    }
333
334    // CRITICAL: Call finalize-preinit to reset WASI state AFTER all imports.
335    // This clears file handles from the WASI adapter and wasi-libc so they
336    // don't get captured in the memory snapshot. Without this, restored
337    // instances get "unknown handle index" errors.
338    call_finalize_preinit(&mut store, &instance).await?;
339
340    // Phase 3: Snapshot the initialized state back into the component.
341    let snapshot_bytes = wizer
342        .snapshot_component(
343            &cx,
344            &mut WasmtimeWizerComponent {
345                store: &mut store,
346                instance,
347            },
348        )
349        .await
350        .map_err(|e| e.context("Failed to pre-initialize component"))?;
351
352    // Phase 4: Restore _initialize exports stripped by wasmtime-wizer.
353    //
354    // wasmtime-wizer removes _initialize exports from all pre-initialized modules,
355    // but the component's CoreInstance sections still reference them as instantiation
356    // arguments. We add back empty (no-op) _initialize functions so the component
357    // remains valid when loaded into wasmtime.
358    restore_initialize_exports(&snapshot_bytes)
359}
360
361/// Restore `_initialize` exports that wasmtime-wizer strips during snapshot.
362///
363/// wasmtime-wizer's rewrite step removes `_initialize` from all pre-initialized
364/// modules. However, the component's `CoreInstance` sections still reference
365/// `_initialize` as instantiation arguments. This function adds back no-op
366/// `_initialize` function exports to any module that's missing one.
367/// Slice `bytes` by a wasmparser section range.
368///
369/// wasmparser 0.257 widened section ranges to `u64` so it can describe 64-bit
370/// modules. Everything here is parsed from an in-memory slice, so the narrowing
371/// only fails on a target where the offsets genuinely do not fit in a `usize` —
372/// in which case the buffer could not have been read in the first place.
373fn slice_range<'a>(bytes: &'a [u8], range: &std::ops::Range<u64>) -> Result<&'a [u8]> {
374    let start = usize::try_from(range.start)?;
375    let end = usize::try_from(range.end)?;
376    Ok(&bytes[start..end])
377}
378
379fn restore_initialize_exports(component_bytes: &[u8]) -> Result<Vec<u8>> {
380    // Pass 1: Find which modules have _initialize and which import it.
381    let mut modules_with_init: HashSet<u32> = HashSet::new();
382    let mut any_module_imports_init = false;
383    let mut module_index = 0u32;
384
385    for payload in wasmparser::Parser::new(0).parse_all(component_bytes) {
386        if let wasmparser::Payload::ModuleSection {
387            unchecked_range: range,
388            ..
389        } = payload?
390        {
391            let module_bytes = slice_range(component_bytes, &range)?;
392            // Use a fresh parser at offset 0 for the module slice
393            for inner in wasmparser::Parser::new(0).parse_all(module_bytes) {
394                match inner? {
395                    wasmparser::Payload::ExportSection(reader) => {
396                        for export in reader {
397                            if export?.name == "_initialize" {
398                                modules_with_init.insert(module_index);
399                            }
400                        }
401                    }
402                    wasmparser::Payload::ImportSection(reader) => {
403                        // Each section entry is a *group* since the compact
404                        // imports proposal, so flatten before matching names.
405                        for import in reader.into_imports() {
406                            if import?.name == "_initialize" {
407                                any_module_imports_init = true;
408                            }
409                        }
410                    }
411                    _ => {}
412                }
413            }
414            module_index += 1;
415        }
416    }
417
418    if !any_module_imports_init {
419        return Ok(component_bytes.to_vec());
420    }
421
422    // Pass 2: Rebuild the component, adding _initialize to modules that lack it.
423    let mut component = wasm_encoder::Component::new();
424    module_index = 0;
425    let mut depth = 0u32;
426
427    for payload in wasmparser::Parser::new(0).parse_all(component_bytes) {
428        let payload = payload?;
429
430        // Track nesting depth — only process top-level sections
431        match &payload {
432            wasmparser::Payload::Version { .. } => {
433                if depth > 0 {
434                    // Nested component/module version — skip, handled by parent
435                    depth += 1;
436                    continue;
437                }
438                depth += 1;
439                continue; // Skip — Component::new() writes the header
440            }
441            wasmparser::Payload::End { .. } => {
442                depth -= 1;
443                continue; // Skip — finish() writes this
444            }
445            _ => {
446                if depth > 1 {
447                    // Inside a nested module/component — skip individual payloads
448                    continue;
449                }
450            }
451        }
452
453        match payload {
454            wasmparser::Payload::ModuleSection {
455                unchecked_range: range,
456                ..
457            } => {
458                let module_bytes = slice_range(component_bytes, &range)?;
459
460                if !modules_with_init.contains(&module_index) {
461                    let patched = add_noop_initialize(module_bytes)?;
462                    component.section(&wasm_encoder::RawSection {
463                        id: wasm_encoder::ComponentSectionId::CoreModule as u8,
464                        data: &patched,
465                    });
466                } else {
467                    component.section(&wasm_encoder::RawSection {
468                        id: wasm_encoder::ComponentSectionId::CoreModule as u8,
469                        data: module_bytes,
470                    });
471                }
472                module_index += 1;
473            }
474            other => {
475                if let Some((id, range)) = other.as_section() {
476                    component.section(&wasm_encoder::RawSection {
477                        id,
478                        data: slice_range(component_bytes, &range)?,
479                    });
480                }
481            }
482        }
483    }
484
485    Ok(component.finish())
486}
487
488/// Add a no-op `_initialize` function export to a core module.
489///
490/// Parses the module to find type/function counts, then rebuilds it
491/// section-by-section, appending a new type (if needed), function declaration,
492/// code body, and export entry for `_initialize`.
493fn add_noop_initialize(module_bytes: &[u8]) -> Result<Vec<u8>> {
494    use wasm_encoder::reencode::{Reencode, RoundtripReencoder};
495
496    let mut num_types = 0u32;
497    let mut num_imported_funcs = 0u32;
498    let mut num_defined_funcs = 0u32;
499    let mut noop_type_idx = None;
500
501    // First pass: count types/functions and find existing () -> () type
502    for payload in wasmparser::Parser::new(0).parse_all(module_bytes) {
503        match payload? {
504            wasmparser::Payload::TypeSection(reader) => {
505                for ty in reader.into_iter() {
506                    let ty = ty?;
507                    for sub in ty.types() {
508                        if let wasmparser::CompositeInnerType::Func(func_ty) =
509                            &sub.composite_type.inner
510                            && func_ty.params().is_empty()
511                            && func_ty.results().is_empty()
512                        {
513                            noop_type_idx = Some(num_types);
514                        }
515                        num_types += 1;
516                    }
517                }
518            }
519            wasmparser::Payload::ImportSection(reader) => {
520                // Must flatten each group: this count defines where the
521                // defined-function index space starts, so a group that expands
522                // to several imported functions has to contribute all of them.
523                for import in reader.into_imports() {
524                    if matches!(import?.ty, wasmparser::TypeRef::Func(_)) {
525                        num_imported_funcs += 1;
526                    }
527                }
528            }
529            wasmparser::Payload::FunctionSection(reader) => {
530                num_defined_funcs = reader.count();
531            }
532            wasmparser::Payload::CodeSectionStart { .. } => {}
533            _ => {}
534        }
535    }
536
537    let num_funcs = num_imported_funcs + num_defined_funcs;
538    let noop_type = noop_type_idx.unwrap_or(num_types);
539    let noop_func_index = num_funcs;
540    let needs_new_type = noop_type_idx.is_none();
541
542    // Second pass: rebuild module using reencode for most sections.
543    // For the code section, we use the saved range to create a CodeSectionReader.
544    let mut encoder = wasm_encoder::Module::new();
545    let mut reencode = RoundtripReencoder;
546
547    for payload in wasmparser::Parser::new(0).parse_all(module_bytes) {
548        match payload? {
549            wasmparser::Payload::Version { .. } => {}
550            wasmparser::Payload::TypeSection(reader) => {
551                let mut types = wasm_encoder::TypeSection::new();
552                reencode.parse_type_section(&mut types, reader)?;
553                if needs_new_type {
554                    types.ty().function([], []);
555                }
556                encoder.section(&types);
557            }
558            wasmparser::Payload::FunctionSection(reader) => {
559                let mut funcs = wasm_encoder::FunctionSection::new();
560                reencode.parse_function_section(&mut funcs, reader)?;
561                funcs.function(noop_type);
562                encoder.section(&funcs);
563            }
564            wasmparser::Payload::ExportSection(reader) => {
565                let mut exports = wasm_encoder::ExportSection::new();
566                reencode.parse_export_section(&mut exports, reader)?;
567                exports.export(
568                    "_initialize",
569                    wasm_encoder::ExportKind::Func,
570                    noop_func_index,
571                );
572                encoder.section(&exports);
573            }
574            wasmparser::Payload::CodeSectionStart { range, .. } => {
575                // Re-parse the code section from the saved range and reencode it,
576                // then append our noop function.
577                let section_data = slice_range(module_bytes, &range)?;
578                let code_reader = wasmparser::CodeSectionReader::new(
579                    wasmparser::BinaryReader::new(section_data, 0),
580                )?;
581
582                let mut code = wasm_encoder::CodeSection::new();
583                reencode.parse_code_section(&mut code, code_reader)?;
584
585                // Append noop function body
586                let mut noop_func = wasm_encoder::Function::new([]);
587                noop_func.instructions().end();
588                code.function(&noop_func);
589                encoder.section(&code);
590            }
591            wasmparser::Payload::CodeSectionEntry(_) => {
592                // Already handled in CodeSectionStart above
593            }
594            wasmparser::Payload::End { .. } => {}
595            other => {
596                if let Some((id, range)) = other.as_section() {
597                    encoder.section(&wasm_encoder::RawSection {
598                        id,
599                        data: slice_range(module_bytes, &range)?,
600                    });
601                }
602            }
603        }
604    }
605
606    Ok(encoder.finish())
607}
608
609/// Add stub implementations for sandbox imports during pre-init.
610fn add_sandbox_stubs(linker: &mut Linker<PreInitCtx>) -> Result<()> {
611    use wasmtime::component::Accessor;
612
613    // The component imports "invoke" for callbacks (wasmtime 40+ uses plain name)
614    linker.root().func_wrap_concurrent(
615        "invoke",
616        |_accessor: &Accessor<PreInitCtx>, (_name, _args): (String, String)| {
617            Box::pin(async move {
618                Ok((Result::<String, String>::Err(
619                    "callbacks not available during pre-init".into(),
620                ),))
621            })
622        },
623    )?;
624
625    // list-callbacks: func() -> list<callback-info>
626    //
627    // Answers with the declarations from `PreInitOptions::callbacks` so the
628    // guest installs their wrappers into the snapshot; empty by default.
629    linker.root().func_new(
630        "list-callbacks",
631        |ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
632         _func_ty: wasmtime::component::types::ComponentFunc,
633         _params: &[Val],
634         results: &mut [Val]| {
635            let declared = ctx
636                .data()
637                .callbacks
638                .iter()
639                .map(|cb| {
640                    Val::Record(vec![
641                        ("name".to_string(), Val::String(cb.name.clone())),
642                        (
643                            "description".to_string(),
644                            Val::String(cb.description.clone()),
645                        ),
646                        (
647                            "parameters-schema-json".to_string(),
648                            Val::String(cb.parameters_schema_json.clone()),
649                        ),
650                    ])
651                })
652                .collect();
653            results[0] = Val::List(declared);
654            Ok(())
655        },
656    )?;
657
658    // report-trace: func(lineno: u32, event-json: string, context-json: string)
659    linker.root().func_new(
660        "report-trace",
661        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
662         _func_ty: wasmtime::component::types::ComponentFunc,
663         _params: &[Val],
664         _results: &mut [Val]| {
665            // No-op - trace events during init can be ignored
666            Ok(())
667        },
668    )?;
669
670    // get-execution-options: func() -> execution-options
671    // Pre-initialization does not execute user code, so no options are needed.
672    linker.root().func_new(
673        "get-execution-options",
674        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
675         _func_ty: wasmtime::component::types::ComponentFunc,
676         _params: &[Val],
677         results: &mut [Val]| {
678            results[0] = Val::Record(vec![
679                ("python-tracing".to_string(), Val::Bool(false)),
680                ("reuse-empty-callbacks".to_string(), Val::Bool(false)),
681            ]);
682            Ok(())
683        },
684    )?;
685
686    // report-output: func(stream-id: u32, data: string)
687    linker.root().func_new(
688        "report-output",
689        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
690         _func_ty: wasmtime::component::types::ComponentFunc,
691         _params: &[Val],
692         _results: &mut [Val]| {
693            // No-op - output during init can be ignored
694            Ok(())
695        },
696    )?;
697
698    // Add network stubs (TCP and TLS interfaces)
699    add_network_stubs(linker)?;
700
701    Ok(())
702}
703
704/// TCP error type for pre-init stubs.
705/// This mirrors the WIT variant `tcp-error` so wasmtime can lower/lift it.
706#[derive(
707    wasmtime::component::ComponentType, wasmtime::component::Lift, wasmtime::component::Lower,
708)]
709#[component(variant)]
710enum PreInitTcpError {
711    #[component(name = "connection-refused")]
712    ConnectionRefused,
713    #[component(name = "connection-reset")]
714    ConnectionReset,
715    #[component(name = "timed-out")]
716    TimedOut,
717    #[component(name = "host-not-found")]
718    HostNotFound,
719    #[component(name = "io-error")]
720    IoError(String),
721    #[component(name = "not-permitted")]
722    NotPermitted(String),
723    #[component(name = "invalid-handle")]
724    InvalidHandle,
725}
726
727/// TLS error type for pre-init stubs.
728/// This mirrors the WIT variant `tls-error`.
729#[derive(
730    wasmtime::component::ComponentType, wasmtime::component::Lift, wasmtime::component::Lower,
731)]
732#[component(variant)]
733enum PreInitTlsError {
734    #[component(name = "tcp")]
735    Tcp(PreInitTcpError),
736    #[component(name = "handshake-failed")]
737    HandshakeFailed(String),
738    #[component(name = "certificate-error")]
739    CertificateError(String),
740    #[component(name = "invalid-handle")]
741    InvalidHandle,
742}
743
744/// Add stub implementations for network imports during pre-init.
745///
746/// These stubs return errors if called - networking isn't available during pre-init.
747/// The stubs are needed so the component can be instantiated.
748///
749/// Note: The WIT declares these as sync `func` but we use fiber-based async on the host
750/// (`func_wrap_async`), which appears blocking to the guest but allows async I/O on the host.
751fn add_network_stubs(linker: &mut Linker<PreInitCtx>) -> Result<()> {
752    // Get or create the eryx:net/tcp interface
753    let mut tcp_instance = linker
754        .instance("eryx:net/tcp@0.1.0")
755        .map_err(|e| e.context("Failed to get eryx:net/tcp instance"))?;
756
757    // tcp.connect: func(host: string, port: u16, timeout-ms: u32) -> result<tcp-handle, tcp-error>
758    tcp_instance.func_wrap_async(
759        "connect",
760        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
761         (_host, _port, _timeout_ms): (String, u16, u32)| {
762            Box::new(async move {
763                Ok((Result::<u32, PreInitTcpError>::Err(
764                    PreInitTcpError::NotPermitted(
765                        "networking not available during pre-init".into(),
766                    ),
767                ),))
768            })
769        },
770    )?;
771
772    // tcp.read: func(handle: tcp-handle, len: u32, timeout-ms: u32) -> result<list<u8>, tcp-error>
773    tcp_instance.func_wrap_async(
774        "read",
775        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
776         (_handle, _len, _timeout_ms): (u32, u32, u32)| {
777            Box::new(async move {
778                Ok((Result::<Vec<u8>, PreInitTcpError>::Err(
779                    PreInitTcpError::NotPermitted(
780                        "networking not available during pre-init".into(),
781                    ),
782                ),))
783            })
784        },
785    )?;
786
787    // tcp.write: func(handle: tcp-handle, timeout-ms: u32, data: list<u8>) -> result<u32, tcp-error>
788    tcp_instance.func_wrap_async(
789        "write",
790        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
791         (_handle, _timeout_ms, _data): (u32, u32, Vec<u8>)| {
792            Box::new(async move {
793                Ok((Result::<u32, PreInitTcpError>::Err(
794                    PreInitTcpError::NotPermitted(
795                        "networking not available during pre-init".into(),
796                    ),
797                ),))
798            })
799        },
800    )?;
801
802    // tcp.close: func(handle: tcp-handle)
803    tcp_instance.func_wrap(
804        "close",
805        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>, (_handle,): (u32,)| {
806            // No-op - handle doesn't exist anyway
807            Ok(())
808        },
809    )?;
810
811    // Get or create the eryx:net/tls interface
812    let mut tls_instance = linker
813        .instance("eryx:net/tls@0.1.0")
814        .map_err(|e| e.context("Failed to get eryx:net/tls instance"))?;
815
816    // tls.upgrade: func(tcp: tcp-handle, hostname: string, timeout-ms: u32) -> result<tls-handle, tls-error>
817    tls_instance.func_wrap_async(
818        "upgrade",
819        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
820         (_tcp_handle, _hostname, _timeout_ms): (u32, String, u32)| {
821            Box::new(async move {
822                Ok((Result::<u32, PreInitTlsError>::Err(
823                    PreInitTlsError::HandshakeFailed(
824                        "networking not available during pre-init".into(),
825                    ),
826                ),))
827            })
828        },
829    )?;
830
831    // tls.read: func(handle: tls-handle, len: u32, timeout-ms: u32) -> result<list<u8>, tls-error>
832    tls_instance.func_wrap_async(
833        "read",
834        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
835         (_handle, _len, _timeout_ms): (u32, u32, u32)| {
836            Box::new(async move {
837                Ok((Result::<Vec<u8>, PreInitTlsError>::Err(
838                    PreInitTlsError::HandshakeFailed(
839                        "networking not available during pre-init".into(),
840                    ),
841                ),))
842            })
843        },
844    )?;
845
846    // tls.write: func(handle: tls-handle, timeout-ms: u32, data: list<u8>) -> result<u32, tls-error>
847    tls_instance.func_wrap_async(
848        "write",
849        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>,
850         (_handle, _timeout_ms, _data): (u32, u32, Vec<u8>)| {
851            Box::new(async move {
852                Ok((Result::<u32, PreInitTlsError>::Err(
853                    PreInitTlsError::HandshakeFailed(
854                        "networking not available during pre-init".into(),
855                    ),
856                ),))
857            })
858        },
859    )?;
860
861    // tls.close: func(handle: tls-handle)
862    tls_instance.func_wrap(
863        "close",
864        |_ctx: wasmtime::StoreContextMut<'_, PreInitCtx>, (_handle,): (u32,)| {
865            // No-op - handle doesn't exist anyway
866            Ok(())
867        },
868    )?;
869
870    Ok(())
871}
872
873/// Call the execute export to import modules during pre-init.
874async fn call_execute_for_imports(
875    store: &mut Store<PreInitCtx>,
876    instance: &Instance,
877    imports: &[String],
878) -> Result<()> {
879    let import_code = imports
880        .iter()
881        .map(|module| format!("import {module}"))
882        .collect::<Vec<_>>()
883        .join("\n");
884
885    call_execute_code(store, instance, &import_code, "imports").await
886}
887
888/// Call the execute export with arbitrary Python code during pre-init.
889///
890/// The `label` is used in error messages to identify what kind of code failed
891/// (e.g., "imports", "setup code").
892async fn call_execute_code(
893    store: &mut Store<PreInitCtx>,
894    instance: &Instance,
895    code: &str,
896    label: &str,
897) -> Result<()> {
898    let execute_func = find_execute_func(store, instance)?;
899
900    let args = [Val::String(code.to_string())];
901    let mut results = vec![Val::Bool(false)];
902
903    execute_func
904        .call_async(&mut *store, &args, &mut results)
905        .await
906        .map_err(|e| e.context(format!("Failed to execute {label} during pre-init")))?;
907
908    match &results[0] {
909        Val::Result(Ok(_)) => Ok(()),
910        Val::Result(Err(Some(error_val))) => {
911            let error_msg = match error_val.as_ref() {
912                Val::String(s) => s.clone(),
913                other => format!("unexpected error value: {other:?}"),
914            };
915            Err(anyhow!(
916                "Pre-init {label} execution failed: {error_msg}\nCode:\n{code}"
917            ))
918        }
919        Val::Result(Err(None)) => Err(anyhow!(
920            "Pre-init {label} execution failed with unknown error\nCode:\n{code}"
921        )),
922        other => {
923            tracing::warn!("Unexpected result type from execute during pre-init: {other:?}");
924            Ok(())
925        }
926    }
927}
928
929/// Find the `execute` export function on the WASM instance.
930fn find_execute_func(store: &mut Store<PreInitCtx>, instance: &Instance) -> Result<Func> {
931    if let Some(func) = instance.get_func(&mut *store, "execute") {
932        Ok(func)
933    } else if let Some(func) = instance.get_func(&mut *store, "[async]execute") {
934        Ok(func)
935    } else {
936        let (_item, exports_idx) = instance
937            .get_export(&mut *store, None, "exports")
938            .ok_or_else(|| anyhow!("No 'exports' or 'execute' export found"))?;
939
940        let execute_idx = instance
941            .get_export_index(&mut *store, Some(&exports_idx), "execute")
942            .ok_or_else(|| anyhow!("No 'execute' in exports interface"))?;
943
944        instance
945            .get_func(&mut *store, execute_idx)
946            .ok_or_else(|| anyhow!("Could not get execute func from index"))
947    }
948}
949
950/// Call the finalize-preinit export to reset WASI state after imports.
951async fn call_finalize_preinit(store: &mut Store<PreInitCtx>, instance: &Instance) -> Result<()> {
952    // Find the finalize-preinit function
953    let finalize_func = instance
954        .get_func(&mut *store, "finalize-preinit")
955        .ok_or_else(|| anyhow!("finalize-preinit export not found"))?;
956
957    // Call it (no arguments, no return value)
958    let args: [Val; 0] = [];
959    let mut results: [Val; 0] = [];
960
961    finalize_func
962        .call_async(&mut *store, &args, &mut results)
963        .await
964        .map_err(|e| e.context("Failed to call finalize-preinit"))?;
965
966    Ok(())
967}
968
969/// Errors that can occur during pre-initialization.
970#[derive(Debug, Clone)]
971#[non_exhaustive]
972pub enum PreInitError {
973    /// Failed to create wasmtime engine.
974    Engine(String),
975    /// Failed to compile component.
976    Compile(String),
977    /// Failed to instantiate component.
978    Instantiate(String),
979    /// Python initialization failed.
980    PythonInit(String),
981    /// Import failed during pre-init.
982    Import(String),
983    /// Component transform failed.
984    Transform(String),
985}
986
987impl std::fmt::Display for PreInitError {
988    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989        match self {
990            Self::Engine(e) => write!(f, "failed to create wasmtime engine: {e}"),
991            Self::Compile(e) => write!(f, "failed to compile component: {e}"),
992            Self::Instantiate(e) => write!(f, "failed to instantiate component: {e}"),
993            Self::PythonInit(e) => write!(f, "Python initialization failed: {e}"),
994            Self::Import(e) => write!(f, "import failed during pre-init: {e}"),
995            Self::Transform(e) => write!(f, "component transform failed: {e}"),
996        }
997    }
998}
999
1000impl std::error::Error for PreInitError {}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn test_preinit_error_display() {
1008        let err = PreInitError::PythonInit("test error".to_string());
1009        assert!(err.to_string().contains("test error"));
1010    }
1011
1012    #[test]
1013    fn test_preinit_error_import_display() {
1014        let err = PreInitError::Import("numpy not found".to_string());
1015        assert!(err.to_string().contains("numpy not found"));
1016    }
1017}