Skip to main content

forge_host/
runtime.rs

1//! Plugin runtime built on `wasmtime`.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use thiserror::Error;
7use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
8use wasmtime::{AsContextMut, Engine as WtEngine, Store, StoreLimits, StoreLimitsBuilder};
9use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
10
11use forge_ir::{Diagnostic, Ir, PluginInfo};
12use forge_ir_bindgen::bindings;
13use forge_ir_bindgen::convert::{self, ResourceKindRepr, StageErrorRepr};
14
15// -----------------------------------------------------------------------------
16// Public outputs
17// -----------------------------------------------------------------------------
18
19#[derive(Debug, Error)]
20pub enum StageError {
21    #[error("plugin rejected input: {reason}")]
22    Rejected {
23        reason: String,
24        diagnostics: Vec<Diagnostic>,
25    },
26    #[error("plugin trap or malformed return: {0}")]
27    PluginBug(String),
28    #[error("plugin config invalid: {0}")]
29    ConfigInvalid(String),
30    #[error("plugin exceeded {0:?}")]
31    ResourceExceeded(ResourceKind),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ResourceKind {
36    Fuel,
37    Memory,
38    Time,
39    OutputSize,
40}
41
42#[derive(Debug, Clone)]
43pub struct TransformOutput {
44    pub spec: Ir,
45    pub diagnostics: Vec<Diagnostic>,
46}
47
48#[derive(Debug, Clone)]
49pub struct GenerationOutput {
50    pub files: Vec<OutputFile>,
51    pub diagnostics: Vec<Diagnostic>,
52}
53
54#[derive(Debug, Clone)]
55pub struct OutputFile {
56    pub path: String,
57    pub content: Vec<u8>,
58    pub mode: FileMode,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileMode {
63    Text,
64    Binary,
65    Executable,
66}
67
68// -----------------------------------------------------------------------------
69// Limits
70// -----------------------------------------------------------------------------
71
72#[derive(Debug, Clone, Copy)]
73pub struct Limits {
74    pub fuel: u64,
75    pub memory_bytes: usize,
76    pub wall_clock_ms: u64,
77    pub output_files_max: u32,
78    pub output_total_bytes_max: u64,
79    pub output_per_file_bytes_max: u64,
80}
81
82impl Limits {
83    pub const fn transformer() -> Self {
84        Self {
85            fuel: 5_000_000_000,
86            memory_bytes: 128 * 1024 * 1024,
87            wall_clock_ms: 5_000,
88            output_files_max: 0,
89            output_total_bytes_max: 0,
90            output_per_file_bytes_max: 0,
91        }
92    }
93
94    pub const fn generator() -> Self {
95        Self {
96            fuel: 50_000_000_000,
97            memory_bytes: 512 * 1024 * 1024,
98            wall_clock_ms: 30_000,
99            output_files_max: 10_000,
100            output_total_bytes_max: 256 * 1024 * 1024,
101            output_per_file_bytes_max: 16 * 1024 * 1024,
102        }
103    }
104}
105
106// -----------------------------------------------------------------------------
107// Engine
108// -----------------------------------------------------------------------------
109
110/// Shared `wasmtime::Engine` plus a background thread that ticks the epoch
111/// counter so per-store wall-clock deadlines can fire.
112///
113/// One `Engine` per process is enough; cloning a `Plugin` reuses the engine.
114#[derive(Clone)]
115pub struct Engine {
116    inner: Arc<EngineInner>,
117}
118
119struct EngineInner {
120    wt: WtEngine,
121    /// Held to keep the epoch ticker alive for the engine's lifetime.
122    _ticker: EpochTicker,
123}
124
125impl std::fmt::Debug for Engine {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("Engine").finish_non_exhaustive()
128    }
129}
130
131impl Engine {
132    /// Build an engine with no on-disk compilation cache. Every plugin is
133    /// recompiled from scratch on load. Used by tests and the harness where
134    /// a shared cache directory would add cross-run coupling.
135    pub fn new() -> Result<Self, EngineError> {
136        Self::build(None)
137    }
138
139    /// Build an engine backed by wasmtime's on-disk compilation cache rooted
140    /// at `cache_dir`. Compiling a plugin component (`Plugin::load_*`) is the
141    /// dominant per-invocation cost — hundreds of milliseconds of Cranelift
142    /// codegen for a multi-MB plugin — and it is pure: a function of the wasm
143    /// bytes, the compiler config, and the wasmtime version. The cache keys on
144    /// exactly those, so a second `forge` run (or a sibling process in a
145    /// parallel regen) deserialises the prior artifact in single-digit
146    /// milliseconds instead of recompiling.
147    ///
148    /// Cache hits never change generated output: the cached entry is the same
149    /// machine code Cranelift would have produced, and wasmtime's own
150    /// version/config fingerprint invalidates stale entries (a wasmtime bump
151    /// simply misses and recompiles).
152    pub fn with_cache(cache_dir: &std::path::Path) -> Result<Self, EngineError> {
153        let mut cc = wasmtime::CacheConfig::new();
154        cc.with_directory(cache_dir);
155        let cache = wasmtime::Cache::new(cc).map_err(|e| EngineError::Cache(e.to_string()))?;
156        Self::build(Some(cache))
157    }
158
159    fn build(cache: Option<wasmtime::Cache>) -> Result<Self, EngineError> {
160        let mut cfg = wasmtime::Config::new();
161        cfg.wasm_component_model(true)
162            .consume_fuel(true)
163            .epoch_interruption(true);
164        // Determinism: disable nondeterministic relaxed-simd lowerings.
165        cfg.relaxed_simd_deterministic(true);
166        if let Some(cache) = cache {
167            cfg.cache(Some(cache));
168        }
169
170        let wt = WtEngine::new(&cfg).map_err(|e| EngineError::Init(e.to_string()))?;
171
172        // Tick at 10ms granularity. Wall-clock deadlines are coarse — that's
173        // fine; the goal is "kill runaway plugins", not millisecond precision.
174        let ticker = EpochTicker::spawn(wt.clone(), Duration::from_millis(10));
175
176        Ok(Engine {
177            inner: Arc::new(EngineInner {
178                wt,
179                _ticker: ticker,
180            }),
181        })
182    }
183
184    pub fn raw(&self) -> &WtEngine {
185        &self.inner.wt
186    }
187}
188
189#[derive(Debug, Error)]
190pub enum EngineError {
191    #[error("wasmtime engine init failed: {0}")]
192    Init(String),
193    #[error("compilation cache init failed: {0}")]
194    Cache(String),
195}
196
197/// Background thread that increments the engine's epoch counter at a fixed
198/// cadence. Dropping it stops the thread.
199struct EpochTicker {
200    stop: Arc<std::sync::atomic::AtomicBool>,
201    handle: Option<std::thread::JoinHandle<()>>,
202}
203
204impl EpochTicker {
205    fn spawn(engine: WtEngine, cadence: Duration) -> Self {
206        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
207        let stop_t = stop.clone();
208        let handle = std::thread::spawn(move || {
209            while !stop_t.load(std::sync::atomic::Ordering::Relaxed) {
210                std::thread::sleep(cadence);
211                engine.increment_epoch();
212            }
213        });
214        EpochTicker {
215            stop,
216            handle: Some(handle),
217        }
218    }
219}
220
221impl Drop for EpochTicker {
222    fn drop(&mut self) {
223        self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
224        if let Some(h) = self.handle.take() {
225            let _ = h.join();
226        }
227    }
228}
229
230// -----------------------------------------------------------------------------
231// HostState — per-Store
232// -----------------------------------------------------------------------------
233
234/// Per-invocation state held in `wasmtime::Store<HostState>`.
235///
236/// The `wasi` field is a *deny-all* `WasiCtx`: no preopens, no environment,
237/// no inherited stdio. We wire `wasmtime_wasi` into the linker only because
238/// the `wasm32-wasip2` rust libstd unconditionally imports WASI interfaces
239/// (clocks, filesystem, stdio, exit, ...). With a deny-all context, calls
240/// fail at runtime — same outcome as a trap, but using the well-tested
241/// wasmtime-wasi resource handling instead of hand-rolled stubs.
242pub struct HostState {
243    pub limits: Limits,
244    pub log_lines: Vec<(forge_ir::LogLevel, String)>,
245    pub store_limits: StoreLimits,
246    pub resource_table: ResourceTable,
247    pub wasi: WasiCtx,
248}
249
250impl std::fmt::Debug for HostState {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("HostState")
253            .field("limits", &self.limits)
254            .field("log_lines", &self.log_lines.len())
255            .finish_non_exhaustive()
256    }
257}
258
259impl HostState {
260    fn new(limits: Limits) -> Self {
261        let store_limits = StoreLimitsBuilder::new()
262            .memory_size(limits.memory_bytes)
263            .build();
264        // Deny-all WASI: no preopens, no env, no stdio inheritance. Plugins
265        // that try to read/write/exit get a runtime error from wasmtime-wasi.
266        let wasi = WasiCtxBuilder::new().build();
267        HostState {
268            limits,
269            log_lines: Vec::new(),
270            store_limits,
271            resource_table: ResourceTable::new(),
272            wasi,
273        }
274    }
275}
276
277impl WasiView for HostState {
278    fn ctx(&mut self) -> WasiCtxView<'_> {
279        WasiCtxView {
280            ctx: &mut self.wasi,
281            table: &mut self.resource_table,
282        }
283    }
284}
285
286// `wasmtime::component::bindgen!` emits a separate `Host` trait per world,
287// even though both worlds import the same `host-api` interface. The `types`
288// and `stage` interfaces also generate (empty) `Host` traits because they
289// are `use`d. We have to implement all three per world; macro-expand the
290// impls once per world and route through the world-neutral helpers in
291// `case`.
292macro_rules! impl_host_api {
293    ($world:ident) => {
294        impl bindings::$world::forge::plugin::types::Host for HostState {}
295        impl bindings::$world::forge::plugin::stage::Host for HostState {}
296
297        impl bindings::$world::forge::plugin::host_api::Host for HostState {
298            fn log(
299                &mut self,
300                level: bindings::$world::forge::plugin::host_api::LogLevel,
301                message: String,
302            ) -> wasmtime::Result<()> {
303                use bindings::$world::forge::plugin::host_api::LogLevel as L;
304                let lv = match level {
305                    L::Trace => forge_ir::LogLevel::Trace,
306                    L::Debug => forge_ir::LogLevel::Debug,
307                    L::Info => forge_ir::LogLevel::Info,
308                    L::Warn => forge_ir::LogLevel::Warn,
309                    L::Error => forge_ir::LogLevel::Error,
310                };
311                match lv {
312                    forge_ir::LogLevel::Trace => {
313                        tracing::trace!(target: "plugin", "{message}")
314                    }
315                    forge_ir::LogLevel::Debug => {
316                        tracing::debug!(target: "plugin", "{message}")
317                    }
318                    forge_ir::LogLevel::Info => {
319                        tracing::info!(target: "plugin", "{message}")
320                    }
321                    forge_ir::LogLevel::Warn => {
322                        tracing::warn!(target: "plugin", "{message}")
323                    }
324                    forge_ir::LogLevel::Error => {
325                        tracing::error!(target: "plugin", "{message}")
326                    }
327                }
328                self.log_lines.push((lv, message));
329                Ok(())
330            }
331
332            fn case_convert(
333                &mut self,
334                input: String,
335                style: bindings::$world::forge::plugin::host_api::CaseStyle,
336            ) -> wasmtime::Result<String> {
337                use bindings::$world::forge::plugin::host_api::CaseStyle as S;
338                let local = match style {
339                    S::Snake => case::Style::Snake,
340                    S::Kebab => case::Style::Kebab,
341                    S::Camel => case::Style::Camel,
342                    S::Pascal => case::Style::Pascal,
343                    S::ScreamingSnake => case::Style::ScreamingSnake,
344                };
345                Ok(case::convert(&input, local))
346            }
347        }
348    };
349}
350
351impl_host_api!(transformer);
352impl_host_api!(generator);
353
354mod case {
355    /// World-neutral case style matching the WIT `case-style` enum.
356    #[derive(Debug, Clone, Copy)]
357    pub enum Style {
358        Snake,
359        Kebab,
360        Camel,
361        Pascal,
362        ScreamingSnake,
363    }
364
365    /// Split an identifier into ASCII word fragments. Recognises the common
366    /// boundaries — case changes, digits, and explicit `_`/`-`/space — and
367    /// drops everything else. Pure, deterministic.
368    fn split(input: &str) -> Vec<String> {
369        let mut words: Vec<String> = Vec::new();
370        let mut cur = String::new();
371        let mut prev_lower = false;
372        for ch in input.chars() {
373            if ch == '_' || ch == '-' || ch.is_whitespace() {
374                if !cur.is_empty() {
375                    words.push(std::mem::take(&mut cur));
376                }
377                prev_lower = false;
378            } else if ch.is_ascii_uppercase() {
379                if prev_lower && !cur.is_empty() {
380                    words.push(std::mem::take(&mut cur));
381                }
382                cur.push(ch.to_ascii_lowercase());
383                prev_lower = false;
384            } else {
385                cur.push(ch);
386                prev_lower = ch.is_ascii_lowercase();
387            }
388        }
389        if !cur.is_empty() {
390            words.push(cur);
391        }
392        words
393    }
394
395    pub fn convert(input: &str, style: Style) -> String {
396        let words = split(input);
397        match style {
398            Style::Snake => words.join("_"),
399            Style::Kebab => words.join("-"),
400            Style::ScreamingSnake => words
401                .iter()
402                .map(|w| w.to_ascii_uppercase())
403                .collect::<Vec<_>>()
404                .join("_"),
405            Style::Camel => words
406                .iter()
407                .enumerate()
408                .map(|(i, w)| if i == 0 { w.clone() } else { capitalize(w) })
409                .collect::<String>(),
410            Style::Pascal => words.iter().map(|w| capitalize(w)).collect::<String>(),
411        }
412    }
413
414    fn capitalize(w: &str) -> String {
415        let mut chars = w.chars();
416        match chars.next() {
417            None => String::new(),
418            Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
419        }
420    }
421
422    #[cfg(test)]
423    mod tests {
424        use super::*;
425        #[test]
426        fn snake() {
427            assert_eq!(convert("HelloWorld", Style::Snake), "hello_world");
428            assert_eq!(convert("hello-world", Style::Snake), "hello_world");
429            assert_eq!(convert("hello world", Style::Snake), "hello_world");
430        }
431        #[test]
432        fn pascal() {
433            assert_eq!(convert("hello_world", Style::Pascal), "HelloWorld");
434        }
435        #[test]
436        fn camel() {
437            assert_eq!(convert("hello_world", Style::Camel), "helloWorld");
438        }
439        #[test]
440        fn kebab() {
441            assert_eq!(convert("HelloWorld", Style::Kebab), "hello-world");
442        }
443        #[test]
444        fn screaming() {
445            assert_eq!(convert("helloWorld", Style::ScreamingSnake), "HELLO_WORLD");
446        }
447    }
448}
449
450// -----------------------------------------------------------------------------
451// Plugin
452// -----------------------------------------------------------------------------
453
454#[derive(Debug, Error)]
455pub enum LoadError {
456    #[error("failed to compile plugin component: {0}")]
457    Compile(String),
458    #[error("failed to link plugin: {0}")]
459    Link(String),
460    #[error("failed to instantiate plugin: {0}")]
461    Instantiate(String),
462    #[error("failed to fetch plugin info: {0}")]
463    Info(String),
464    #[error("plugin info failed conversion: {0}")]
465    Convert(String),
466}
467
468/// Loaded plugin component. Holds the compiled component + cached `info()`.
469/// Each invocation builds a fresh `Store` with its own resource budget.
470pub struct Plugin {
471    engine: Engine,
472    component: Component,
473    info: PluginInfo,
474    config_schema: String,
475    kind: PluginKind,
476}
477
478impl std::fmt::Debug for Plugin {
479    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480        f.debug_struct("Plugin")
481            .field("info", &self.info)
482            .field("kind", &self.kind)
483            .finish_non_exhaustive()
484    }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum PluginKind {
489    Transformer,
490    Generator,
491}
492
493impl Plugin {
494    pub fn info(&self) -> &PluginInfo {
495        &self.info
496    }
497
498    pub fn config_schema(&self) -> &str {
499        &self.config_schema
500    }
501
502    pub fn kind(&self) -> PluginKind {
503        self.kind
504    }
505
506    /// Load a transformer plugin from raw component bytes.
507    pub fn load_transformer(engine: &Engine, bytes: &[u8]) -> Result<Self, LoadError> {
508        let component =
509            Component::new(engine.raw(), bytes).map_err(|e| LoadError::Compile(e.to_string()))?;
510        let linker = build_transformer_linker(engine, &component).map_err(LoadError::Link)?;
511        let mut store = make_store(engine, Limits::transformer());
512        let inst =
513            bindings::transformer::IrTransformer::instantiate(&mut store, &component, &linker)
514                .map_err(|e| LoadError::Instantiate(e.to_string()))?;
515        let info_wit = inst
516            .forge_plugin_transformer_api()
517            .call_info(&mut store)
518            .map_err(|e| LoadError::Info(e.to_string()))?;
519        let schema = inst
520            .forge_plugin_transformer_api()
521            .call_config_schema(&mut store)
522            .map_err(|e| LoadError::Info(e.to_string()))?;
523        let info = convert::transformer::plugin_info_from_wit(info_wit);
524        Ok(Plugin {
525            engine: engine.clone(),
526            component,
527            info,
528            config_schema: schema,
529            kind: PluginKind::Transformer,
530        })
531    }
532
533    /// Load a generator plugin from raw component bytes.
534    pub fn load_generator(engine: &Engine, bytes: &[u8]) -> Result<Self, LoadError> {
535        let component =
536            Component::new(engine.raw(), bytes).map_err(|e| LoadError::Compile(e.to_string()))?;
537        let linker = build_generator_linker(engine, &component).map_err(LoadError::Link)?;
538        let mut store = make_store(engine, Limits::generator());
539        let inst = bindings::generator::CodeGenerator::instantiate(&mut store, &component, &linker)
540            .map_err(|e| LoadError::Instantiate(e.to_string()))?;
541        let info_wit = inst
542            .forge_plugin_generator_api()
543            .call_info(&mut store)
544            .map_err(|e| LoadError::Info(e.to_string()))?;
545        let schema = inst
546            .forge_plugin_generator_api()
547            .call_config_schema(&mut store)
548            .map_err(|e| LoadError::Info(e.to_string()))?;
549        let info = convert::generator::plugin_info_from_wit(info_wit);
550        Ok(Plugin {
551            engine: engine.clone(),
552            component,
553            info,
554            config_schema: schema,
555            kind: PluginKind::Generator,
556        })
557    }
558
559    /// Run a transformer.
560    pub fn transform(
561        &self,
562        spec: Ir,
563        config: &str,
564        limits: Limits,
565    ) -> Result<TransformOutput, StageError> {
566        if self.kind != PluginKind::Transformer {
567            return Err(StageError::PluginBug(
568                "plugin loaded as transformer but called as generator".into(),
569            ));
570        }
571        let linker = build_transformer_linker(&self.engine, &self.component)
572            .map_err(|e| StageError::PluginBug(format!("link: {e}")))?;
573        let mut store = make_store(&self.engine, limits);
574        let inst =
575            bindings::transformer::IrTransformer::instantiate(&mut store, &self.component, &linker)
576                .map_err(|e| StageError::PluginBug(format!("instantiate: {e}")))?;
577        let wit_ir = convert::transformer::ir_to_wit(spec);
578        let result = inst.forge_plugin_transformer_api().call_transform(
579            store.as_context_mut(),
580            &wit_ir,
581            config,
582        );
583        let result = map_call_error(result, &store)?;
584        match result {
585            Ok(out) => {
586                let spec = convert::transformer::ir_from_wit(out.spec)
587                    .map_err(|e| StageError::PluginBug(format!("ir convert: {e}")))?;
588                let diagnostics = out
589                    .diagnostics
590                    .into_iter()
591                    .map(convert::transformer::diagnostic_from_wit)
592                    .collect::<Result<Vec<_>, _>>()
593                    .map_err(|e| StageError::PluginBug(format!("diag convert: {e}")))?;
594                Ok(TransformOutput { spec, diagnostics })
595            }
596            Err(stage_err) => Err(stage_error_from_repr(
597                convert::transformer::stage_error_from_wit(stage_err),
598            )),
599        }
600    }
601
602    /// Run a generator.
603    pub fn generate(
604        &self,
605        spec: Ir,
606        config: &str,
607        limits: Limits,
608    ) -> Result<GenerationOutput, StageError> {
609        if self.kind != PluginKind::Generator {
610            return Err(StageError::PluginBug(
611                "plugin loaded as generator but called as transformer".into(),
612            ));
613        }
614        let linker = build_generator_linker(&self.engine, &self.component)
615            .map_err(|e| StageError::PluginBug(format!("link: {e}")))?;
616        let mut store = make_store(&self.engine, limits);
617        let inst =
618            bindings::generator::CodeGenerator::instantiate(&mut store, &self.component, &linker)
619                .map_err(|e| StageError::PluginBug(format!("instantiate: {e}")))?;
620        let wit_ir = convert::generator::ir_to_wit(spec);
621        let result = inst.forge_plugin_generator_api().call_generate(
622            store.as_context_mut(),
623            &wit_ir,
624            config,
625        );
626        let result = map_call_error(result, &store)?;
627        match result {
628            Ok(out) => {
629                let mut total_bytes: u64 = 0;
630                let files: Vec<OutputFile> = out
631                    .files
632                    .into_iter()
633                    .map(|f| {
634                        total_bytes = total_bytes.saturating_add(f.content.len() as u64);
635                        OutputFile {
636                            path: f.path,
637                            content: f.content,
638                            mode: match f.mode {
639                                bindings::generator::exports::forge::plugin::generator_api::FileMode::Text => FileMode::Text,
640                                bindings::generator::exports::forge::plugin::generator_api::FileMode::Binary => FileMode::Binary,
641                                bindings::generator::exports::forge::plugin::generator_api::FileMode::Executable => FileMode::Executable,
642                            },
643                        }
644                    })
645                    .collect();
646                if files.len() as u64 > limits.output_files_max as u64 {
647                    return Err(StageError::ResourceExceeded(ResourceKind::OutputSize));
648                }
649                if total_bytes > limits.output_total_bytes_max {
650                    return Err(StageError::ResourceExceeded(ResourceKind::OutputSize));
651                }
652                let diagnostics = out
653                    .diagnostics
654                    .into_iter()
655                    .map(convert::generator::diagnostic_from_wit)
656                    .collect::<Result<Vec<_>, _>>()
657                    .map_err(|e| StageError::PluginBug(format!("diag convert: {e}")))?;
658                Ok(GenerationOutput { files, diagnostics })
659            }
660            Err(stage_err) => Err(stage_error_from_repr(
661                convert::generator::stage_error_from_wit(stage_err),
662            )),
663        }
664    }
665}
666
667/// Translate the world-neutral `StageErrorRepr` from `forge-ir-bindgen` into
668/// the host's `StageError`. They are isomorphic; the indirection only exists
669/// to avoid pulling `forge-host` into `forge-ir-bindgen`'s dependency graph.
670fn stage_error_from_repr(r: StageErrorRepr) -> StageError {
671    match r {
672        StageErrorRepr::Rejected {
673            reason,
674            diagnostics,
675        } => StageError::Rejected {
676            reason,
677            diagnostics,
678        },
679        StageErrorRepr::PluginBug(s) => StageError::PluginBug(s),
680        StageErrorRepr::ConfigInvalid(s) => StageError::ConfigInvalid(s),
681        StageErrorRepr::ResourceExceeded(k) => StageError::ResourceExceeded(match k {
682            ResourceKindRepr::Fuel => ResourceKind::Fuel,
683            ResourceKindRepr::Memory => ResourceKind::Memory,
684            ResourceKindRepr::Time => ResourceKind::Time,
685            ResourceKindRepr::OutputSize => ResourceKind::OutputSize,
686        }),
687    }
688}
689
690/// Build a `Linker` for the transformer world: registers `host-api` and
691/// stubs every other import declared by the loaded component as a trap.
692/// The latter handles WASI imports that the rust libstd `wasm32-wasip2`
693/// target inserts unconditionally — `wasi:cli/exit`, `wasi:cli/stderr`,
694/// `wasi:filesystem/*`, etc. The sandbox model rejects all of them, so
695/// satisfying the linker with traps is the right behaviour: a plugin that
696/// actually exercises one fails loudly rather than silently leaking out
697/// of the sandbox.
698fn build_transformer_linker(
699    engine: &Engine,
700    _component: &Component,
701) -> Result<Linker<HostState>, String> {
702    let mut linker = Linker::<HostState>::new(engine.raw());
703    bindings::transformer::IrTransformer::add_to_linker::<HostState, HasSelf<HostState>>(
704        &mut linker,
705        |s| s,
706    )
707    .map_err(|e| e.to_string())?;
708    wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
709    Ok(linker)
710}
711
712fn build_generator_linker(
713    engine: &Engine,
714    _component: &Component,
715) -> Result<Linker<HostState>, String> {
716    let mut linker = Linker::<HostState>::new(engine.raw());
717    bindings::generator::CodeGenerator::add_to_linker::<HostState, HasSelf<HostState>>(
718        &mut linker,
719        |s| s,
720    )
721    .map_err(|e| e.to_string())?;
722    wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
723    Ok(linker)
724}
725
726fn make_store(engine: &Engine, limits: Limits) -> Store<HostState> {
727    let mut store = Store::new(engine.raw(), HostState::new(limits));
728    let _ = store.set_fuel(limits.fuel);
729    // Epoch ticks at 10ms; deadline = wall_clock_ms / 10, minimum 1.
730    let deadline = limits.wall_clock_ms.div_ceil(10).max(1);
731    store.set_epoch_deadline(deadline);
732    store.epoch_deadline_trap();
733    store.limiter(|s| &mut s.store_limits);
734    store
735}
736
737/// Translate the wasmtime call result. The `Result` we're handed already
738/// distinguishes traps (anyhow::Error) from successful component returns
739/// (which may themselves be `Result<T, stage_error>` if the WIT signature
740/// includes a `result<...>`).
741fn map_call_error<T>(res: wasmtime::Result<T>, store: &Store<HostState>) -> Result<T, StageError> {
742    match res {
743        Ok(v) => Ok(v),
744        Err(e) => {
745            // Distinguish resource exhaustion from generic traps. wasmtime
746            // surfaces these as specific error variants via `downcast_ref`.
747            if let Some(t) = e.downcast_ref::<wasmtime::Trap>() {
748                match t {
749                    wasmtime::Trap::OutOfFuel => {
750                        return Err(StageError::ResourceExceeded(ResourceKind::Fuel))
751                    }
752                    wasmtime::Trap::Interrupt => {
753                        return Err(StageError::ResourceExceeded(ResourceKind::Time))
754                    }
755                    _ => {}
756                }
757            }
758            // Memory limit violations surface as a generic error from
759            // ResourceLimiter; check via the message + state.
760            let msg = format!("{e:#}");
761            if msg.contains("memory") && store.data().limits.memory_bytes > 0 {
762                // Best-effort detection. Fall through to plugin-bug if not
763                // matched.
764                if msg.contains("grow") || msg.contains("limit") {
765                    return Err(StageError::ResourceExceeded(ResourceKind::Memory));
766                }
767            }
768            Err(StageError::PluginBug(msg))
769        }
770    }
771}