Skip to main content

submilli_engine/runtime/
mod.rs

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