Skip to main content

warp_runtime/
engine.rs

1use crate::profile::Profile;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::Arc;
4use std::time::Duration;
5use wasmtime::{Config, Engine, Module, Store};
6
7/// The GC heap a run starts with: reserved, committed lazily by the OS; a heap starting empty collects (walking every
8/// frame of a deep recursion) at each of its many small growths
9const GC_HEAP_INITIAL_BYTES: u64 = 1 << 30;
10/// The main thread's stack kept for the host when wasm gets the rest (`give_main_stack_to_wasm`): the compiler and host
11/// words run below the wasm frames
12const HOST_STACK_BYTES: usize = 16 << 20;
13
14/// The wasm call stack of engines made from now on; 0 keeps wasmtime's default (512 KiB), safe on any thread
15static WASM_STACK_BYTES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
16
17/// Engine settings every warp run shares: GC and typed function references. NaNs are canonicalized where they are
18/// observed (floats::canonical_nan, P119), not after every float operation, which doubled the cost of float loops.
19/// Machine code compiled with these settings (`warp compile --aot`, `warp build`) loads into any engine made
20/// from them, with or without a compiler.
21pub fn deterministic_config() -> Config {
22	let mut config = Config::new();
23	config.wasm_gc(true);
24	config.wasm_function_references(true);
25	config.gc_heap_initial_size(GC_HEAP_INITIAL_BYTES);
26	// warp runs core modules, no components: the machine code then also loads into a runtime built without the
27	// component model (the warp-runtime stub), where it is off anyway
28	#[cfg(feature = "compiler")]
29	config.wasm_component_model(false);
30	config.concurrency_support(false);
31	let wasm_stack = wasm_stack_bytes();
32	if wasm_stack > 0 {
33		// wasmtime wants an async stack at least as large; warp makes no async calls, so none is allocated
34		config.async_stack_size(wasm_stack).max_wasm_stack(wasm_stack);
35	}
36	config
37}
38
39/// The wasm call stack engines get, 0 for wasmtime's default: a thread running wasm of such an engine needs a stack
40/// this large besides its own frames (the self-hosted stage workers, src/compiler/self_hosted.rs)
41pub fn wasm_stack_bytes() -> usize {
42	WASM_STACK_BYTES.load(std::sync::atomic::Ordering::Relaxed)
43}
44
45/// Called on the main thread before any engine is made: wasm runs on the main thread and may use its stack but
46/// HOST_STACK_BYTES, a million frames instead of about 15000 (card deep-recursion). Other threads (tests, tasks)
47/// keep the default, which fits their smaller stacks.
48pub fn give_main_stack_to_wasm() {
49	let wasm_stack = main_stack_bytes().saturating_sub(HOST_STACK_BYTES);
50	if wasm_stack > 512 << 10 {
51		WASM_STACK_BYTES.store(wasm_stack, std::sync::atomic::Ordering::Relaxed);
52	}
53}
54
55#[cfg(target_os = "macos")]
56fn main_stack_bytes() -> usize {
57	// SAFETY: asks about the calling thread only
58	unsafe { libc::pthread_get_stacksize_np(libc::pthread_self()) }
59}
60
61#[cfg(all(unix, not(target_os = "macos")))]
62fn main_stack_bytes() -> usize {
63	let mut limit = libc::rlimit { rlim_cur: 0, rlim_max: 0 };
64	// SAFETY: fills the limit it is given
65	let known = unsafe { libc::getrlimit(libc::RLIMIT_STACK, &mut limit) } == 0;
66	match known {
67		true if limit.rlim_cur != libc::RLIM_INFINITY => limit.rlim_cur as usize,
68		_ => 0,
69	}
70}
71
72#[cfg(not(unix))]
73fn main_stack_bytes() -> usize {
74	0
75}
76
77/// deterministic_config with epoch interruption, where a run checks its time limit (and a task whether to stop), and
78/// with fuel metering when `fuel`: the settings of warp's engines. Epoch checks cost a few percent, fuel about four
79/// times on calls, so only runs that ask for it count steps (decision fuel-metering).
80pub fn metered_config(fuel: bool) -> Config {
81	let mut config = deterministic_config();
82	config.epoch_interruption(true);
83	config.consume_fuel(fuel);
84	config
85}
86
87/// How often a ticking engine's epoch advances: a run passes its time limit by at most about this much
88pub const EPOCH_TICK: Duration = Duration::from_millis(10);
89
90/// `engine` with a thread advancing its epoch every EPOCH_TICK for the rest of the process: its stores check their
91/// time limit at each tick. For engines that live as long as the process (shared or standalone).
92pub fn ticking(engine: Engine) -> Engine {
93	let ticked = engine.clone();
94	std::thread::spawn(move || loop {
95		std::thread::sleep(EPOCH_TICK);
96		ticked.increment_epoch();
97	});
98	engine
99}
100
101/// A store whose run stops: with `fuel` steps on an engine that meters fuel, else after `time` of computing in one turn
102/// (each call from the host into the program: main, a frame, an event handler, so a game or a server runs for as long as
103/// it likes); its memories and GC heap capped (`cap_memory`)
104pub fn limited_store<T: 'static>(engine: &Engine, data: T, fuel: u64, time: Duration) -> Store<T> {
105	checked_store(engine, data, fuel, time, || Ok(()))
106}
107
108/// limited_store that also runs `checkpoint` at every epoch tick (a task's stop and pause), failing the run when it
109/// fails
110pub fn checked_store<T: 'static>(
111	engine: &Engine,
112	data: T,
113	fuel: u64,
114	time: Duration,
115	checkpoint: impl FnMut() -> wasmtime::Result<()> + Send + Sync + 'static,
116) -> Store<T> {
117	store_with(engine, data, fuel, time, checkpoint, None)
118}
119
120/// limited_store sampling where `module`'s run spends its time when WARP_PROFILE names a file (profile.rs)
121pub fn profiled_store<T: 'static>(engine: &Engine, data: T, fuel: u64, time: Duration, module: &Module) -> Store<T> {
122	store_with(engine, data, fuel, time, || Ok(()), Profile::of(engine, module))
123}
124
125fn store_with<T: 'static>(
126	engine: &Engine,
127	data: T,
128	fuel: u64,
129	time: Duration,
130	mut checkpoint: impl FnMut() -> wasmtime::Result<()> + Send + Sync + 'static,
131	profile: Option<Profile>,
132) -> Store<T> {
133	let mut store = Store::new(engine, data);
134	let mut clock = store.set_fuel(fuel).is_err().then(|| ComputingClock::new(time));
135	let new_turn = clock.as_ref().map(|clock| clock.new_turn.clone());
136	if new_turn.is_some() || profile.is_some() {
137		let hook_profile = profile.clone();
138		store.call_hook(move |store, hook| {
139			if let (Some(new_turn), wasmtime::CallHook::CallingWasm) = (&new_turn, hook) {
140				new_turn.store(true, Ordering::Relaxed);
141			}
142			if let Some(profile) = &hook_profile {
143				profile.call_hook(&store, hook);
144			}
145			Ok(())
146		});
147	}
148	store.epoch_deadline_callback(move |store| {
149		if let Some(profile) = &profile {
150			profile.sample(&store);
151		}
152		checkpoint()?;
153		if let Some(clock) = clock.as_mut() {
154			clock.check()?;
155		}
156		Ok(wasmtime::UpdateDeadline::Continue(1))
157	});
158	store.set_epoch_deadline(1);
159	cap_memory(&mut store);
160	store
161}
162
163/// The failure of a run past its time limit
164#[derive(Debug)]
165pub struct OutOfTime(pub Duration);
166
167impl std::fmt::Display for OutOfTime {
168	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169		let variable = crate::fuel::TIME_LIMIT_VARIABLE;
170		write!(f, "out of time after {:?} of computing in one turn (give it more with {variable}=<seconds>)", self.0)
171	}
172}
173
174impl std::error::Error for OutOfTime {}
175
176/// The computing time of a turn, counted from its first check: the CPU time of the thread running it
177struct ComputingClock {
178	limit: Duration,
179	started: Option<Duration>,
180	/// set when the host calls into the program: the next check starts counting again
181	new_turn: Arc<AtomicBool>,
182}
183
184impl ComputingClock {
185	fn new(limit: Duration) -> Self {
186		ComputingClock { limit, started: None, new_turn: Arc::default() }
187	}
188
189	fn check(&mut self) -> wasmtime::Result<()> {
190		let now = thread_cpu_time();
191		if self.new_turn.swap(false, Ordering::Relaxed) {
192			self.started = None;
193		}
194		match now.saturating_sub(*self.started.get_or_insert(now)) > self.limit {
195			true => Err(OutOfTime(self.limit).into()),
196			false => Ok(()),
197		}
198	}
199}
200
201#[cfg(unix)]
202pub(crate) fn thread_cpu_time() -> Duration {
203	let mut time = libc::timespec { tv_sec: 0, tv_nsec: 0 };
204	// SAFETY: fills the timespec it is given, about the calling thread
205	unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut time) };
206	Duration::new(time.tv_sec as u64, time.tv_nsec as u32)
207}
208
209/// Without a thread clock: the time since the process's first check, waiting included
210#[cfg(not(unix))]
211pub(crate) fn thread_cpu_time() -> Duration {
212	static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
213	START.get_or_init(std::time::Instant::now).elapsed()
214}
215
216/// The size any one memory or GC heap of a run may grow to: a runaway program stops with an error instead of
217/// swapping the machine to death (a 154 GB process took the Mac down, 2026-10-09)
218pub const DEFAULT_MEMORY_CAP_MB: usize = 2048;
219/// Environment variable that overrides `DEFAULT_MEMORY_CAP_MB`
220pub const MEMORY_CAP_VARIABLE: &str = "WARP_MEMORY_CAP_MB";
221const BYTES_PER_MB: usize = 1 << 20;
222
223/// The cap in bytes: `WARP_MEMORY_CAP_MB` if it is set to a number, else the default
224pub fn memory_cap_bytes() -> usize {
225	static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
226	*CAP.get_or_init(|| {
227		let megabytes = std::env::var(MEMORY_CAP_VARIABLE).ok().and_then(|value| value.trim().parse().ok());
228		megabytes.unwrap_or(DEFAULT_MEMORY_CAP_MB).saturating_mul(BYTES_PER_MB)
229	})
230}
231
232/// Every growth of the store's linear memories and its GC heap (wasmtime grows the GC heap's memory through the
233/// same limiter) past `memory_cap_bytes` fails the run with an error naming the cap
234pub fn cap_memory<T>(store: &mut Store<T>) {
235	// a zero-sized limiter: leaking its box allocates nothing
236	store.limiter(|_| Box::leak(Box::new(MemoryCap)));
237}
238
239struct MemoryCap;
240
241impl wasmtime::ResourceLimiter for MemoryCap {
242	fn memory_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
243		let cap = memory_cap_bytes();
244		if desired > cap {
245			let refusal = format!(
246				"memory cap: growing a memory to {} MB passes the cap of {} MB ({MEMORY_CAP_VARIABLE})",
247				desired / BYTES_PER_MB,
248				cap / BYTES_PER_MB
249			);
250			// also on stderr: wasmtime drops this error for the GC heap and traps with "GC heap out of memory"
251			eprintln!("{refusal}");
252			wasmtime::bail!(refusal);
253		}
254		Ok(maximum.is_none_or(|maximum| desired <= maximum))
255	}
256
257	fn table_growing(&mut self, _current: usize, desired: usize, maximum: Option<usize>) -> wasmtime::Result<bool> {
258		Ok(maximum.is_none_or(|maximum| desired <= maximum))
259	}
260}