Skip to main content

submilli_engine/runtime/
limits.rs

1//! Per-store [`ResourceLimiter`] enforcing a single aggregate cap on
2//! GC heap, linear memory, and host-attached bytes (e.g. compiled regexes).
3//! `host_attached_bytes` is `Arc<AtomicU64>` so externref `Drop` impls can
4//! decrement it without a `&mut TenantLimits` borrow.
5
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8
9use wasmtime::{ResourceLimiter, Store};
10
11use super::StoreData;
12
13pub const DEFAULT_MAX_STORE_BYTES: u64 = 50 * 1024 * 1024;
14
15#[derive(Debug, Clone, Copy)]
16pub struct MemoryCapExceeded {
17    pub requested: u64,
18    pub already_observed: u64,
19    pub already_host_attached: u64,
20    pub cap: u64,
21}
22
23impl std::fmt::Display for MemoryCapExceeded {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(
26            f,
27            "memory cap exceeded: requested {} host bytes; \
28             already observed {} bytes + {} host bytes against {} cap",
29            self.requested, self.already_observed, self.already_host_attached, self.cap
30        )
31    }
32}
33
34impl std::error::Error for MemoryCapExceeded {}
35
36/// The run reached its memory cap. Like spent fuel or an expired deadline this
37/// ends the run; a program cannot catch it. The refusal that raised it is the
38/// error's source.
39#[derive(Debug, Clone, Copy)]
40pub struct MemoryExhausted;
41
42impl std::fmt::Display for MemoryExhausted {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.write_str("memory exhausted")
45    }
46}
47
48/// Whether `err` is the tenant's cap refusing memory: the engine growing the
49/// GC heap, or the host charging bytes it holds for the program.
50pub fn is_memory_exhausted(err: &wasmtime::Error) -> bool {
51    err.is::<MemoryExhausted>()
52        || err.is::<wasmtime::GcHeapOutOfMemory<()>>()
53        || err.is::<MemoryCapExceeded>()
54}
55
56/// Names `err` as [`MemoryExhausted`] when the cap refused memory, keeping the
57/// refusal as its source; any other error is returned unchanged.
58pub(crate) fn name_memory_exhaustion(err: wasmtime::Error) -> wasmtime::Error {
59    if !is_memory_exhausted(&err) || err.is::<MemoryExhausted>() {
60        return err;
61    }
62    err.context(MemoryExhausted)
63}
64
65/// Native working memory admitted before allocating and refunded on every exit.
66pub struct HostBytes {
67    counter: Arc<AtomicU64>,
68    bytes: u64,
69}
70
71impl HostBytes {
72    pub fn new(limits: &TenantLimits, bytes: u64) -> Result<Self, MemoryCapExceeded> {
73        limits.charge_host_bytes(bytes)?;
74        Ok(Self {
75            counter: limits.host_attached_counter(),
76            bytes,
77        })
78    }
79    pub(crate) fn grow(
80        &mut self,
81        limits: &TenantLimits,
82        bytes: u64,
83    ) -> Result<(), MemoryCapExceeded> {
84        limits.charge_host_bytes(bytes)?;
85        self.bytes = self.bytes.saturating_add(bytes);
86        Ok(())
87    }
88}
89
90impl Drop for HostBytes {
91    fn drop(&mut self) {
92        let _ = self
93            .counter
94            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
95                Some(current.saturating_sub(self.bytes))
96            });
97    }
98}
99
100pub struct TenantLimits {
101    pub max_total_bytes: u64,
102    observed_bytes: u64,
103    peak_bytes: AtomicU64,
104    host_attached_bytes: Arc<AtomicU64>,
105}
106
107impl TenantLimits {
108    pub fn new(max_total_bytes: u64) -> Self {
109        Self {
110            max_total_bytes,
111            observed_bytes: 0,
112            peak_bytes: AtomicU64::new(0),
113            host_attached_bytes: Arc::new(AtomicU64::new(0)),
114        }
115    }
116
117    pub fn observed_bytes(&self) -> u64 {
118        self.observed_bytes
119    }
120
121    /// High-water mark of admitted engine memory plus charged host bytes, not RSS.
122    pub fn peak_bytes(&self) -> u64 {
123        self.peak_bytes.load(Ordering::Relaxed)
124    }
125
126    pub fn host_attached_bytes(&self) -> u64 {
127        self.host_attached_bytes.load(Ordering::Relaxed)
128    }
129
130    pub fn host_attached_counter(&self) -> Arc<AtomicU64> {
131        Arc::clone(&self.host_attached_bytes)
132    }
133
134    /// Charges happen on the store thread, but cancelled Git workers can refund
135    /// their reservations concurrently during execution cleanup.
136    pub fn charge_host_bytes(&self, n: u64) -> Result<(), MemoryCapExceeded> {
137        self.host_attached_bytes
138            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
139                let next = current.checked_add(n)?;
140                (self.observed_bytes.saturating_add(next) <= self.max_total_bytes).then_some(next)
141            })
142            .map(|previous| {
143                self.peak_bytes.fetch_max(
144                    self.observed_bytes
145                        .saturating_add(previous)
146                        .saturating_add(n),
147                    Ordering::Relaxed,
148                );
149            })
150            .map_err(|current| MemoryCapExceeded {
151                requested: n,
152                already_observed: self.observed_bytes,
153                already_host_attached: current,
154                cap: self.max_total_bytes,
155            })
156    }
157
158    pub fn release_host_bytes(&self, n: u64) {
159        let _ = self.host_attached_bytes.fetch_update(
160            Ordering::Relaxed,
161            Ordering::Relaxed,
162            |current| Some(current.saturating_sub(n)),
163        );
164    }
165}
166
167impl ResourceLimiter for TenantLimits {
168    fn memory_growing(
169        &mut self,
170        current: usize,
171        desired: usize,
172        _maximum: Option<usize>,
173    ) -> wasmtime::Result<bool> {
174        let delta = (desired as u64).saturating_sub(current as u64);
175        let next = self.observed_bytes.saturating_add(delta);
176        // host_attached_bytes is read-only here; writes belong to charge/release_host_bytes.
177        let host = self.host_attached_bytes.load(Ordering::Relaxed);
178        if next.saturating_add(host) > self.max_total_bytes {
179            return Ok(false);
180        }
181        self.observed_bytes = next;
182        self.peak_bytes
183            .fetch_max(next.saturating_add(host), Ordering::Relaxed);
184        Ok(true)
185    }
186
187    fn table_growing(
188        &mut self,
189        _current: usize,
190        _desired: usize,
191        _maximum: Option<usize>,
192    ) -> wasmtime::Result<bool> {
193        Ok(true)
194    }
195}
196
197/// Call once, immediately after constructing the `Store`, before instantiating any module.
198pub fn install_tenant_limits(store: &mut Store<StoreData>) {
199    store.limiter(|data| &mut data.tenant_limits);
200}
201
202/// Usage survives successful execution, traps, and cleanup of host allocations.
203#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
204pub struct ExecutionUsage {
205    /// All fuel spent: `wasm_fuel + host_fuel`.
206    pub fuel: u64,
207    /// Fuel burnt by the program's own Wasm instructions.
208    pub wasm_fuel: u64,
209    /// Fuel charged by host functions for the work they did on the program's
210    /// behalf (see [`super::fuel::charge_host_fuel`]).
211    pub host_fuel: u64,
212    pub memory_peak: u64,
213}
214
215impl ExecutionUsage {
216    pub fn capture(store: &Store<StoreData>, initial_fuel: u64) -> wasmtime::Result<Self> {
217        let remaining = store.get_fuel()?;
218        let fuel = initial_fuel
219            .checked_sub(remaining)
220            .ok_or_else(|| wasmtime::Error::msg("remaining fuel exceeds initial budget"))?;
221        // Batched host charges the engine has not seen yet; a run that Wasm
222        // exhausted with a batch pending overran the budget by that much, and
223        // the report stays within the budget.
224        let fuel = fuel
225            .saturating_add(store.data().host_fuel_pending)
226            .min(initial_fuel);
227        let host_fuel = store.data().host_fuel;
228        let wasm_fuel = fuel
229            .checked_sub(host_fuel)
230            .ok_or_else(|| wasmtime::Error::msg("host fuel exceeds the fuel spent"))?;
231        Ok(Self {
232            fuel,
233            wasm_fuel,
234            host_fuel,
235            memory_peak: store.data().tenant_limits.peak_bytes(),
236        })
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn memory_peak_retains_released_host_allocations_and_excludes_refusals() {
246        let mut limits = TenantLimits::new(1000);
247        assert!(limits.memory_growing(0, 200, None).unwrap());
248        limits.charge_host_bytes(500).unwrap();
249        limits.release_host_bytes(500);
250        assert_eq!(limits.peak_bytes(), 700);
251        limits.charge_host_bytes(100).unwrap();
252        assert!(limits.memory_growing(200, 650, None).unwrap());
253        assert_eq!(limits.peak_bytes(), 750);
254        assert!(limits.charge_host_bytes(300).is_err());
255        assert!(!limits.memory_growing(650, 950, None).unwrap());
256        assert_eq!(limits.peak_bytes(), 750);
257    }
258}