Skip to main content

warp_runtime/
engine.rs

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