Skip to main content

submilli_engine/runtime/
mod.rs

1//! Wasmtime engine configuration for Submilli.
2
3pub mod agents;
4pub(crate) mod array_storage;
5pub mod blocking;
6pub mod call_log;
7pub mod decision;
8pub mod default_filesystem;
9pub mod disk_quota;
10pub mod durable;
11pub mod embedding;
12pub mod exec;
13pub mod filesystem;
14pub mod fs;
15pub mod fuel;
16pub mod gc_singleton;
17pub mod host;
18pub mod intrinsic_types;
19pub mod json;
20pub(crate) mod json_text;
21pub mod limits;
22pub mod llm;
23pub mod mcp;
24pub mod metrics;
25pub mod number;
26pub mod prelude;
27pub mod secrets;
28pub mod security;
29pub mod session_finalizers;
30pub mod session_kv;
31pub mod skills;
32pub mod token_ledger;
33pub mod tools;
34pub mod vfs;
35pub mod watchdog;
36
37pub use agents::{
38    AGENTS_MODULE_NAME, AgentCallError, AgentInfo, AgentOutcome, AgentProvider, AgentRequest,
39    AgentUsage,
40};
41pub use call_log::{BodyCopy, CallOutcome, CallRecord, ModelUsage, PayloadRecord};
42pub use decision::{
43    CallOrigin, CallSite, CallTicket, ConsoleLine, DecisionAction, DecisionCause,
44    DecisionExplanation, DecisionLog, DecisionLogConfig, DecisionLogOutput, DecisionRecord,
45    DecisionRecorder, EntryPath, FailureReasonRecord, FailureRecord, NearMissRecord, PackageLine,
46    RecordObserver, RuleCitation, SourceLine,
47};
48pub use disk_quota::{DiskQuota, Holder, OpenFileGuard, QuotaCharge, QuotaExceeded};
49pub use embedding::{
50    DEFAULT_MAX_EMBEDDING_REQUESTS, DEFAULT_MAX_EXECUTION_EMBEDDING_TOKENS, EMBEDDING_MODULE_NAME,
51    EmbeddingBatch, EmbeddingBoundKind, EmbeddingError, EmbeddingFailureReason, EmbeddingLimitKind,
52    EmbeddingLimits, EmbeddingMalformedReason, EmbeddingModel, EmbeddingProvider,
53    EmbeddingShapeError, EmbeddingTokenBudget, Purpose, SubBatchSettlement,
54    estimate_embedding_tokens,
55};
56pub use exec::{RunResult, dispatch_main_async, instantiate_program_async};
57pub use host::{
58    INTERNAL_MODULE_NAME, NUMBER_MODULE_NAME, host_package_declarations,
59    install_async as install_runtime_async, install_async_for as install_runtime_async_for,
60    install_host_functions as install_runtime_host_functions,
61    install_host_functions_for as install_runtime_host_functions_for,
62    install_store_bound as install_runtime_store_bound, internal_host_package_declarations,
63    stdlib_package_declarations,
64};
65pub use json::JSON_MODULE_NAME;
66pub use limits::{
67    DEFAULT_MAX_STORE_BYTES, MemoryCapExceeded, MemoryExhausted, TenantLimits,
68    install_tenant_limits, is_memory_exhausted,
69};
70pub use llm::{
71    DEFAULT_MAX_ALL_EXECUTIONS_TOKENS, DEFAULT_MAX_EXECUTION_TOKENS, ExecutionTokenBudget,
72    FailureReason, LLM_MODULE_NAME, LlmCallError, LlmFailure, LlmLimitKind, LlmLimits, LlmModel,
73    LlmOutcome, LlmProvider, PromptBoundKind, SharedTokenBudget,
74};
75pub use mcp::{
76    MCP_MODULE_NAME, McpCallError, McpOutcome, McpResponse, McpTransport, install_mcp_async,
77    mcp_call_package_declaration,
78};
79pub use metrics::{HttpMetric, MetricsSink, NoopMetricsSink};
80pub use prelude::bigint::ops::BIGINT_MODULE_NAME;
81pub use prelude::temporal::shared::TEMPORAL_MODULE_NAME;
82pub use secrets::{NoopSecretProvider, SecretProvider};
83pub use security::{AllowAllCheck, AuditDecision, CheckOutcome, SecurityCheck};
84pub use session_kv::{
85    CURSOR_KEY_ID_LEN, CursorKey, CursorKeys, InMemorySessionKv, SessionKvEntry, SessionKvError,
86    SessionKvFuture, SessionKvLimitKind, SessionKvLimits, SessionKvPage, SessionKvStore,
87    SessionKvUsage, entry_bytes, render_key, value_bytes,
88};
89pub use skills::{SKILLS_MODULE_NAME, Skill, SkillError, SkillInfo, SkillProvider};
90pub use tools::{
91    TOOLS_CAPABILITY, TOOLS_MODULE_NAME, TOOLS_PACKAGE, ToolCallError, ToolProvider,
92    install_tools_async, tools_call_package_declaration,
93};
94pub use vfs::{
95    Access, CopyDirError, MAX_MEASURED_DEPTH, MountError, MountSpec, Vfs, VfsMode, copy_host_dir,
96    copy_host_subdir, measure_dir, measure_host_dir, measure_host_dir_skipping_vanished,
97    measure_host_subdir_skipping_vanished, open_host_subdir, regular_files,
98};
99pub use watchdog::Watchdog;
100
101pub use crate::stdlib::http::{
102    AuthProxy, AuthProxyError, HttpClient, HttpError, HttpRequest, HttpResponse, NetworkPolicy,
103    NoopAuthProxy, ReqwestHttpClient,
104};
105
106use std::cell::RefCell;
107use std::io::Write;
108use std::sync::{Arc, Mutex};
109use std::time::Duration;
110
111use wasmtime::{
112    ArrayRef, AsContextMut, Config, Engine, Instance, Linker, Module, OptLevel, Rooted, Store,
113    WasmBacktraceDetails,
114};
115
116use crate::{PackageDeclaration, TypeInfoTable};
117
118/// Filesystem metadata surfaced to scripts via `submilli:fs.info()`: the
119/// active mode and the byte cap the VFS enforces, so the script — and the LLM —
120/// can branch on the sandbox shape. Never exposes the host path.
121#[derive(Debug, Clone)]
122pub struct VfsInfo {
123    pub mode: VfsMode,
124    pub size_limit: Option<u64>,
125}
126
127pub struct StoreData {
128    #[cfg(test)]
129    pub(crate) array_growth: array_storage::GrowthStats,
130    /// After cancelling a host call, the execution owner drains this before
131    /// reusing or releasing the store. Blocking work may still be cleaning up.
132    pub blocking_work: blocking::BlockingWork,
133    pub git: Option<crate::stdlib::git::GitConfig>,
134    pub(crate) git_history: Option<Arc<crate::stdlib::git::log_cache::Cache>>,
135    pub console: Box<dyn Write + Send>,
136    pub vfs: Vfs,
137    pub filesystem: Arc<dyn filesystem::Filesystem>,
138    pub vfs_info: VfsInfo,
139    pub security_check: Arc<dyn SecurityCheck>,
140    pub fs_max_read_size: u64,
141    pub http_client: Arc<dyn HttpClient>,
142    pub http_max_response_size: u64,
143    /// Operator ceiling for a download, including streaming its body.
144    pub http_max_download_timeout_ms: u64,
145    pub auth_proxy: Arc<dyn AuthProxy>,
146    /// Immutable configuration resolved by the trusted session creator.
147    pub variables: Arc<std::collections::BTreeMap<String, String>>,
148    pub secret_provider: Arc<dyn SecretProvider>,
149    /// The outbound `@mcp/<server>` transport the `submilli:mcp.call` host fn
150    /// dispatches through, present when the embedder wires one. `None` in the
151    /// pure-interpreter path, where MCP calls throw "transport not configured".
152    pub mcp_transport: Option<Arc<dyn McpTransport>>,
153    /// The application tools `submilli:tools.call` (`@tools`) dispatches
154    /// through, present only when the embedder wires one. `None` makes every
155    /// call a catchable "no application tools" error.
156    pub tool_provider: Option<Arc<dyn ToolProvider>>,
157    /// Time this execution spent waiting outside the program on a host call
158    /// that must not count against its time limit (an application tool call).
159    /// An embedder's execution timeout subtracts it from the elapsed time.
160    pub clock_paused: std::time::Duration,
161    /// The model provider `submilli:llm` dispatches through, present only when
162    /// the embedder wires one. Left `None` the runtime has no model access at
163    /// all rather than silently completing against some default — the guest
164    /// surface reports that as a catchable configuration error
165    /// ([`LlmCallError::NotConfigured`]), the same rule `session_kv` follows.
166    pub llm_provider: Option<Arc<dyn LlmProvider>>,
167    /// This execution's token budget, reserving against its own ceiling and the
168    /// server-wide one together. `None` in the pure-interpreter path, where
169    /// there is no aggregate to protect and nothing to charge against; the
170    /// prompt-count and prompt-size bounds still apply there, because they
171    /// bound pathological shapes rather than spend. The embedder installs one
172    /// per execution, and dropping it is what returns the reservation.
173    pub llm_budget: Option<Arc<ExecutionTokenBudget>>,
174    /// The provider `submilli:embedding` dispatches through, present only when
175    /// the embedder wires one. `None` means the guest sees a catchable
176    /// [`EmbeddingError::NotConfigured`], never a silent default.
177    pub embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
178    /// This execution's embedding budget (tokens and outbound requests). `None`
179    /// in the pure-interpreter path; dropping it returns unspent reservation.
180    pub embedding_budget: Option<Arc<EmbeddingTokenBudget>>,
181    /// Session-scoped key-value storage, present only when the embedder wires a
182    /// provider. Left `None` the store stays absent rather than silently
183    /// becoming per-execution scratch state that no later `execute` can read —
184    /// the guest surface reports that as a configuration error.
185    pub session_kv: Option<Arc<dyn SessionKvStore>>,
186    /// Durable package-owned resource cleanup supplied by the embedder.
187    pub session_finalizers: Option<Arc<dyn session_finalizers::SessionFinalizers>>,
188    pub finalizing: bool,
189    /// The keys `session.list` cursors are sealed with. `None` seals them with a
190    /// per-process key, so they stop working when the process restarts.
191    pub cursor_keys: Option<Arc<CursorKeys>>,
192    /// The harness `submilli:agents` runs sub-agents through, present only when
193    /// the embedder wires one. `None` makes every call a catchable
194    /// [`AgentCallError::NotConfigured`].
195    pub agent_provider: Option<Arc<dyn AgentProvider>>,
196    /// The harness `submilli:skills` reads skills through, present only when the
197    /// embedder wires one. `None` makes every call a catchable
198    /// [`SkillError::NotConfigured`].
199    pub skill_provider: Option<Arc<dyn SkillProvider>>,
200    /// Embedder sink for host-operation metrics (HTTP transport latencies).
201    /// Defaults to [`NoopMetricsSink`]; the server installs a Sentry-backed one.
202    pub metrics: Arc<dyn metrics::MetricsSink>,
203    pub tenant_limits: TenantLimits,
204    /// Fuel charged by host functions for their own work; the rest of the fuel
205    /// spent went to Wasm instructions. See [`fuel::charge_host_fuel`].
206    pub host_fuel: u64,
207    pub(crate) next_identity_hash: u64,
208    #[cfg(test)]
209    pub(crate) collection_index_reads: u64,
210    #[cfg(test)]
211    pub(crate) object_index_probes: u64,
212    /// Host charges not yet applied to the engine's fuel (see
213    /// [`fuel::HOST_FUEL_BATCH`]).
214    pub host_fuel_pending: u64,
215    /// The engine's fuel right after the last application of pending host
216    /// charges; `None` before the first.
217    pub host_fuel_applied_at: Option<u64>,
218    /// Test-segment labels recorded by `submilli:test.label`, in call order.
219    /// Only the test runner installs that host fn; an ordinary run leaves this
220    /// empty. The runner reads it after `main()` returns to attribute the
221    /// pass/fail outcome to the segment that was open at the time.
222    pub test_labels: RefCell<Vec<String>>,
223    /// Recovered runtime types + the prelude instance handle host functions use
224    /// to build *real* `$string`/`$Array` structs (vtable + payload) instead of
225    /// raw arrays. `None` until the prelude instantiates; set by
226    /// `install_prelude_async`. See [`crate::runtime::host::HostAbi`].
227    pub host_abi: Option<crate::runtime::host::HostAbi>,
228    /// This store's intrinsic types, built on first use; see
229    /// [`crate::runtime::intrinsic_types::intrinsic_types`].
230    pub(crate) intrinsic_types: Option<Arc<crate::runtime::intrinsic_types::IntrinsicTypes>>,
231    /// An immutable global keeps the store's undefined value rooted across calls.
232    pub(crate) undefined_value: Option<wasmtime::Global>,
233    /// The bound-receiver closure environment type, built on first use for the
234    /// same reason as [`Self::intrinsic_types`].
235    pub(crate) closure_receiver_type: Option<wasmtime::StructType>,
236    /// The call-metadata closure environment type, built on first use for the
237    /// same reason as [`Self::intrinsic_types`].
238    pub(crate) call_metadata_type: Option<wasmtime::StructType>,
239    pub(crate) iterator_functions: [Option<wasmtime::Func>; 13],
240    pub(crate) iterator_constants: [Option<wasmtime::Global>; 6],
241    /// The object each static-dispatch binding is as a value, by its string
242    /// form; see `prelude::object::static_value`.
243    pub(crate) static_values: std::collections::BTreeMap<Vec<u16>, wasmtime::Global>,
244    pub(crate) parameter_cache: prelude::arguments::ParameterCache,
245    pub(crate) regex_input: Option<prelude::regex::input::InputCache>,
246    /// Runtime type metadata keyed by package name.
247    pub type_info: std::collections::BTreeMap<String, TypeInfoTable>,
248    /// Depth of the in-flight universal-vtable walk; see
249    /// [`MAX_VTABLE_WALK_DEPTH`].
250    pub vtable_walk_depth: u32,
251    /// Hook entries in the current outer structural walk, including repeated
252    /// visits to shared children. Reset only when the outer walk finishes.
253    pub(crate) vtable_walk_nodes: u32,
254    /// Host-only result marshalling after an effect must not refuse for fuel.
255    pub(crate) settling_host_result: bool,
256    /// Denials the runtime threw, by thrown object; see [`host::ThrownDenials`].
257    pub(crate) thrown_denials: host::ThrownDenials,
258}
259
260/// The nesting the universal-vtable walk allows before it reports a runaway.
261///
262/// Pinned to `serde_json`'s own recursion limit, which is what bounds
263/// `JSON.parse`: a document the runtime is willing to parse must still be
264/// comparable and re-serializable, or `JSON.parse` would accept graphs that
265/// `JSON.stringify` then refuses. Measured headroom: a debug build on a 2 MB
266/// test-harness thread aborts between 160 and 200 levels, so the bound sits
267/// below the point where the native stack runs out.
268pub(crate) const MAX_VTABLE_WALK_DEPTH: u32 = 128;
269
270/// Bounds shared-substructure expansion independently of available fuel.
271pub(crate) const MAX_STRUCTURAL_WALK_NODES: u32 = 100_000;
272
273pub const DEFAULT_FS_MAX_READ_SIZE: u64 = 50 * 1024 * 1024;
274
275pub const DEFAULT_HTTP_MAX_RESPONSE_SIZE: u64 = 50 * 1024 * 1024;
276
277#[derive(Clone, Copy)]
278pub struct LinkedPackageModule<'a> {
279    pub module: &'a Module,
280    pub declaration: &'a PackageDeclaration,
281    pub type_info: &'a TypeInfoTable,
282}
283
284impl StoreData {
285    pub fn with_vfs(vfs: Vfs) -> Self {
286        Self::with_vfs_and_cap(vfs, DEFAULT_MAX_STORE_BYTES)
287    }
288
289    pub fn with_vfs_and_cap(vfs: Vfs, max_store_bytes: u64) -> Self {
290        let vfs_info = VfsInfo {
291            mode: vfs.mode(),
292            size_limit: None,
293        };
294        Self {
295            #[cfg(test)]
296            array_growth: array_storage::GrowthStats::default(),
297            console: Box::new(std::io::stderr()),
298            filesystem: Arc::new(default_filesystem::DefaultFilesystem::new(vfs.clone())),
299            vfs,
300            vfs_info,
301            security_check: security::default_check(),
302            git: None,
303            git_history: None,
304            blocking_work: blocking::BlockingWork::default(),
305            fs_max_read_size: DEFAULT_FS_MAX_READ_SIZE,
306            http_client: crate::stdlib::http::default_http_client(),
307            http_max_response_size: DEFAULT_HTTP_MAX_RESPONSE_SIZE,
308            http_max_download_timeout_ms: 60_000,
309            auth_proxy: crate::stdlib::http::default_auth_proxy(),
310            variables: Arc::default(),
311            secret_provider: Arc::new(secrets::NoopSecretProvider),
312            mcp_transport: None,
313            tool_provider: None,
314            clock_paused: std::time::Duration::ZERO,
315            llm_provider: None,
316            llm_budget: None,
317            embedding_provider: None,
318            embedding_budget: None,
319            session_kv: None,
320            session_finalizers: None,
321            finalizing: false,
322            cursor_keys: None,
323            agent_provider: None,
324            skill_provider: None,
325            metrics: Arc::new(metrics::NoopMetricsSink),
326            tenant_limits: TenantLimits::new(max_store_bytes),
327            host_fuel: 0,
328            #[cfg(test)]
329            collection_index_reads: 0,
330            #[cfg(test)]
331            object_index_probes: 0,
332            next_identity_hash: 0,
333            host_fuel_pending: 0,
334            host_fuel_applied_at: None,
335            test_labels: RefCell::new(Vec::new()),
336            host_abi: None,
337            intrinsic_types: None,
338            undefined_value: None,
339            closure_receiver_type: None,
340            call_metadata_type: None,
341            iterator_functions: [None; 13],
342            iterator_constants: [None; 6],
343            static_values: std::collections::BTreeMap::new(),
344            parameter_cache: Default::default(),
345            regex_input: None,
346            type_info: std::collections::BTreeMap::new(),
347            vtable_walk_depth: 0,
348            vtable_walk_nodes: 0,
349            settling_host_result: false,
350            thrown_denials: host::ThrownDenials::default(),
351        }
352    }
353
354    pub fn install_type_info(&mut self, table: TypeInfoTable) {
355        self.type_info.insert(table.package_name.clone(), table);
356    }
357
358    pub fn with_tempdir() -> std::io::Result<Self> {
359        Ok(Self::with_vfs(Vfs::tempdir()?))
360    }
361}
362
363/// Instantiates each linked package module, running its module-level initializers.
364///
365/// Every thread polling this future must meet
366/// [`RuntimeConfig::native_stack_size`]'s caller contract.
367pub async fn install_package_modules_async(
368    linker: &mut Linker<StoreData>,
369    store: &mut Store<StoreData>,
370    packages: &[LinkedPackageModule<'_>],
371) -> wasmtime::Result<()> {
372    for package in packages {
373        let name = package.declaration.package_name.as_str();
374        if store.data().git.is_none()
375            && package
376                .module
377                .imports()
378                .any(|import| import.module() == "submilli:git")
379        {
380            wasmtime::bail!(
381                "package `{name}` imports submilli:git; configure the blueprint git block"
382            );
383        }
384        // The module's declared name *is* its principal at every gated call, so bind it to the
385        // name the package is being linked under. Without this, a prebuilt `pkg.wasm` naming
386        // itself `main` — or naming another package — would be granted that principal's
387        // permissions and, for `main`, the operator's injected credentials. The bytes are not
388        // necessarily ones this compiler produced: `submilli run` and the server both
389        // instantiate artifacts straight from the package store.
390        match package.module.name() {
391            Some(declared) if declared == name => {}
392            Some(declared) => wasmtime::bail!(
393                "package `{name}`: its module declares the name `{declared}`, which would give \
394                 it that principal's permissions. Rebuild the package."
395            ),
396            None => wasmtime::bail!(
397                "package `{name}`: its module declares no name, so its gated calls cannot be \
398                 attributed. Rebuild the package."
399            ),
400        }
401        store
402            .data_mut()
403            .install_type_info(package.type_info.clone());
404        // Instantiation runs the package's module-level initializers. They are the
405        // package's own code, executing in the package's own module, so identity read
406        // off the running frame names them without any bracketing here.
407        let instance = {
408            let outcome = linker.instantiate_async(&mut *store, package.module).await;
409            name_the_failing_initializer(&mut *store, name, outcome)?
410        };
411        linker.instance(&mut *store, name, instance)?;
412    }
413    Ok(())
414}
415
416/// Initializers run inside the Wasm start function, so anything they throw —
417/// a denial most of all — escapes instantiation as the engine's opaque
418/// `ThrownException`, whose Display is "wasm exception thrown". The thrown
419/// value is still on the store, so recover its text the way an uncaught throw
420/// from `main` is recovered and say which package it came from. Without this an
421/// operator who granted a capability under `main:` rather than the package's own
422/// block — the mistake this attribution makes easy — gets no package, no
423/// capability, and no reason.
424fn name_the_failing_initializer(
425    store: &mut Store<StoreData>,
426    package: &str,
427    outcome: wasmtime::Result<Instance>,
428) -> wasmtime::Result<Instance> {
429    let err = match outcome {
430        Ok(instance) => return Ok(instance),
431        Err(err) => err,
432    };
433    let recovered = exec::uncaught_error(store, err);
434    let Some(thrown) = recovered.downcast_ref::<crate::backtrace::ThrownError>() else {
435        return Err(recovered);
436    };
437    Err(wasmtime::Error::new(crate::backtrace::ThrownError {
438        message: format!(
439            "package `{package}` failed to initialize: {}",
440            thrown.message
441        ),
442        backtrace: thrown.backtrace.clone(),
443        denial: thrown.denial.clone(),
444    }))
445}
446
447/// Native stack bytes per byte of `max_wasm_stack`; see
448/// [`RuntimeConfig::native_stack_size`].
449const NATIVE_STACK_PER_WASM_BYTE: usize = 32;
450const MIN_NATIVE_STACK: usize = 8 * 1024 * 1024;
451
452#[derive(Clone, Debug)]
453pub struct RuntimeConfig {
454    pub fuel: u64,
455    pub max_wasm_stack: usize,
456    pub memory_reservation: u64,
457    pub memory_guard_size: u64,
458    pub memory_reservation_for_growth: u64,
459    pub max_store_bytes: u64,
460    pub timeout: Option<Duration>,
461    pub async_yield_fuel: Option<u64>,
462}
463
464impl Default for RuntimeConfig {
465    fn default() -> Self {
466        Self {
467            // Temporarily very high: large structural JSON.stringify and other
468            // per-code-unit Wasm work still burns fuel, and exhausting it mid-run
469            // is a worse failure than the loose runaway-loop bound this gives up.
470            // The real CPU cap belongs in a wall-clock timeout; revisit once the
471            // hot encoding paths are off the meter.
472            fuel: 1_000_000_000_000,
473            max_wasm_stack: 512 * 1024,
474            memory_reservation: 3 * 1024 * 1024,
475            memory_guard_size: 64 * 1024,
476            memory_reservation_for_growth: 16 * 1024 * 1024,
477            max_store_bytes: DEFAULT_MAX_STORE_BYTES,
478            timeout: None,
479            // 10K fuel ≈ ~10K wasm instructions. At the default fuel budget a
480            // CPU-bound full-budget call yields thousands of times before
481            // exhaustion — fine-grained enough for fair scheduling, coarse enough
482            // that yield overhead stays negligible.
483            async_yield_fuel: Some(10_000),
484        }
485    }
486}
487
488impl RuntimeConfig {
489    /// The native stack a thread running programs under this config needs.
490    ///
491    /// `max_wasm_stack` is an interpreter budget, but a host call that re-enters
492    /// Wasm (a callback passed to `map`, say) nests interpreter frames on the
493    /// thread's own stack. The engine charges each crossing 4 KiB of the budget;
494    /// this bounds re-entry depth, not the thread's available native stack. Debug
495    /// builds have measured about 64 KiB per crossing, so this reservation allows
496    /// 128 KiB per crossing, with an 8 MiB minimum for setup and shallow calls.
497    ///
498    /// Embedders must provide this stack on every thread that polls execution,
499    /// including after an async task migrates between workers. The runners do not
500    /// create a thread or inspect remaining stack space. Poll them near the root
501    /// of the thread, rather than underneath deep native recursion. Undersized
502    /// stacks can overflow and abort the process instead of returning a trap.
503    /// Custom host functions must also bound their own native recursion.
504    ///
505    /// Set a Tokio runtime's `thread_stack_size` for worker threads. Its
506    /// `block_on` caller runs on the calling thread, so size that thread separately
507    /// (also for a current-thread runtime or `pollster::block_on`). Compilation
508    /// separately requires [`crate::compiler_limits::COMPILER_STACK_BYTES`]; use
509    /// the larger requirement when a thread performs both operations.
510    pub fn native_stack_size(&self) -> usize {
511        self.max_wasm_stack
512            .saturating_mul(NATIVE_STACK_PER_WASM_BYTE)
513            .max(MIN_NATIVE_STACK)
514    }
515
516    pub fn engine(&self) -> wasmtime::Result<Engine> {
517        Engine::new(&self.wasmtime_config())
518    }
519
520    /// Same as [`engine`](Self::engine). `Config::async_support` is a no-op in
521    /// wasmtime 44+; this entry point exists so async embedders have a distinct
522    /// call site to evolve independently.
523    pub fn engine_async(&self) -> wasmtime::Result<Engine> {
524        Engine::new(&self.wasmtime_config())
525    }
526
527    pub fn wasmtime_config(&self) -> Config {
528        let mut config = Config::new();
529        // Winch lacks `wasm_gc` + `wasm_function_references`; Pulley is ~10× slower.
530        // Strategy::Auto → Cranelift JIT.
531        //
532        // No optimization passes: the per-request user script is compiled fresh by
533        // `Module::new` (it can't be AOT-cached — it's LLM-generated), and for
534        // short-lived scripts that compile cost dominates the run. The trade is
535        // global, though: precompiled prelude/stdlib share this engine, so they run
536        // unoptimized too. TODO: precompile stdlib + curated packages on a separate
537        // high-opt engine and `deserialize` the optimized artifacts here, so only the
538        // user script pays the unoptimized-codegen tax.
539        config.cranelift_opt_level(OptLevel::None);
540        config.consume_fuel(true);
541        config.max_wasm_stack(self.max_wasm_stack);
542
543        config.epoch_interruption(true);
544
545        config.memory_reservation(self.memory_reservation);
546        config.memory_guard_size(self.memory_guard_size);
547        config.memory_reservation_for_growth(self.memory_reservation_for_growth);
548        config.memory_may_move(true);
549        config.memory_init_cow(true);
550
551        // The engine grants this reservation without consulting the store limiter.
552        // Start at zero so every GC byte is charged to the tenant's aggregate cap,
553        // including stores whose cap differs from this engine's RuntimeConfig.
554        config.gc_heap_reservation(0);
555        config.gc_heap_guard_size(self.memory_guard_size);
556        config.gc_heap_reservation_for_growth(self.memory_reservation_for_growth);
557        config.gc_heap_may_move(true);
558
559        config.wasm_gc(true);
560        config.wasm_function_references(true);
561        config.wasm_exceptions(true);
562
563        config.wasm_backtrace_details(WasmBacktraceDetails::Enable);
564
565        config
566    }
567
568    pub fn store<T>(&self, engine: &Engine, data: T) -> wasmtime::Result<Store<T>> {
569        let mut store = Store::new(engine, data);
570        store.set_fuel(self.fuel)?;
571        // With epoch_interruption(true), an unset deadline traps immediately.
572        // Use 1 for watchdog-tripped timeouts, MAX to effectively disable.
573        let delta = if self.timeout.is_some() { 1 } else { u64::MAX };
574        store.set_epoch_deadline(delta);
575        store.epoch_deadline_trap();
576        Ok(store)
577    }
578
579    pub fn store_async<T>(&self, engine: &Engine, data: T) -> wasmtime::Result<Store<T>> {
580        let mut store = self.store(engine, data)?;
581        store.fuel_async_yield_interval(self.async_yield_fuel)?;
582        Ok(store)
583    }
584
585    /// Arm the configured timeout, failing setup if its thread cannot be created.
586    /// Keep the returned guard alive until guest execution finishes.
587    pub fn arm_timeout(&self, engine: &Engine) -> wasmtime::Result<Option<Watchdog>> {
588        self.timeout
589            .map(|duration| watchdog::arm(engine, duration))
590            .transpose()
591            .map_err(|error| {
592                wasmtime::Error::from(error).context("starting execution timeout watchdog")
593            })
594    }
595
596    /// One-shot runner: load compiled Wasm, instantiate, call `main`, return typed result
597    /// and captured console. Async like the rest of the runtime — a caller that
598    /// isn't on a runtime bridges it itself (`pollster::block_on` for
599    /// compute/`fs`-only programs, a tokio runtime when `http` is involved). For
600    /// live console streaming, build a custom `Store<StoreData>` and call
601    /// [`dispatch_main_async`] directly.
602    ///
603    /// # Native stack
604    ///
605    /// Every thread polling this future must meet [`Self::native_stack_size`]'s
606    /// caller contract. This method does not provision a stack. Compilation
607    /// separately requires [`crate::compiler_limits::COMPILER_STACK_BYTES`].
608    pub async fn run(&self, wasm_bytes: &[u8]) -> wasmtime::Result<RunResult> {
609        self.run_with_type_info(wasm_bytes, None).await
610    }
611
612    /// Runs a compiled script with its result type information and captured console.
613    ///
614    /// # Native stack
615    ///
616    /// Every thread polling this future must meet [`Self::native_stack_size`]'s
617    /// caller contract, just as for [`Self::run`]. Compiling the script separately
618    /// requires [`crate::compiler_limits::COMPILER_STACK_BYTES`].
619    pub async fn run_compiled(
620        &self,
621        compiled: &crate::compile::CompiledScript,
622    ) -> wasmtime::Result<RunResult> {
623        self.run_with_type_info(&compiled.wasm, Some(compiled.type_info.clone()))
624            .await
625    }
626
627    async fn run_with_type_info(
628        &self,
629        wasm_bytes: &[u8],
630        type_info: Option<TypeInfoTable>,
631    ) -> wasmtime::Result<RunResult> {
632        let buf = Arc::new(Mutex::new(Vec::new()));
633        let mut data = StoreData::with_vfs_and_cap(Vfs::tempdir()?, self.max_store_bytes);
634        data.console = Box::new(ConsoleSink(Arc::clone(&buf)));
635        if let Some(type_info) = type_info {
636            data.install_type_info(type_info);
637        }
638        let engine = self.engine()?;
639        let mut store = self.store_async(&engine, data)?;
640        install_tenant_limits(&mut store);
641        let module = Module::new(&engine, wasm_bytes)?;
642        let mut linker = Linker::<StoreData>::new(&engine);
643        install_runtime_async(&mut linker, &mut store).await?;
644        let _watchdog = self.arm_timeout(&engine)?;
645        let inst = instantiate_program_async(&linker, &mut store, &module).await?;
646        let value = dispatch_main_async(&mut store, &inst).await?;
647        // A poisoned ConsoleSink may contain output from an interrupted write.
648        // AGENTS.md permits this poisoned-lock panic instead of recovering partial
649        // output; it does not permit the panic that caused poisoning.
650        let captured = buf.lock().expect("console buffer lock poisoned").clone();
651        let console = String::from_utf8(captured)
652            .map_err(|e| wasmtime::Error::msg(format!("console output not utf-8: {e}")))?;
653        Ok(RunResult { value, console })
654    }
655}
656
657// A panic during writing may leave partial console output. AGENTS.md permits
658// panicking on poisoned access instead of recovering it; the panic that caused
659// poisoning is still subject to the no-panic policy.
660struct ConsoleSink(Arc<Mutex<Vec<u8>>>);
661
662impl Write for ConsoleSink {
663    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
664        self.0
665            .lock()
666            .expect("console buffer lock poisoned")
667            .write(buf)
668    }
669
670    fn flush(&mut self) -> std::io::Result<()> {
671        Ok(())
672    }
673}
674
675/// Decode a Submilli `(ref $string)` (packed UTF-16) into a Rust `String`.
676pub(crate) fn read_submilli_string(
677    mut ctx: impl AsContextMut<Data = StoreData>,
678    msg: Rooted<ArrayRef>,
679) -> wasmtime::Result<String> {
680    let units = host::read_code_units(&mut ctx, msg, "string")?;
681    fuel::charge(ctx, fuel::SCAN, units.len() as u64)?;
682    Ok(String::from_utf16_lossy(&units))
683}