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