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(crate) struct HostBytes {
67    counter: Arc<AtomicU64>,
68    bytes: u64,
69}
70
71impl HostBytes {
72    pub(crate) 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}
80
81impl Drop for HostBytes {
82    fn drop(&mut self) {
83        let _ = self
84            .counter
85            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
86                Some(current.saturating_sub(self.bytes))
87            });
88    }
89}
90
91pub struct TenantLimits {
92    pub max_total_bytes: u64,
93    observed_bytes: u64,
94    peak_bytes: AtomicU64,
95    host_attached_bytes: Arc<AtomicU64>,
96}
97
98impl TenantLimits {
99    pub fn new(max_total_bytes: u64) -> Self {
100        Self {
101            max_total_bytes,
102            observed_bytes: 0,
103            peak_bytes: AtomicU64::new(0),
104            host_attached_bytes: Arc::new(AtomicU64::new(0)),
105        }
106    }
107
108    pub fn observed_bytes(&self) -> u64 {
109        self.observed_bytes
110    }
111
112    /// High-water mark of admitted engine memory plus charged host bytes, not RSS.
113    pub fn peak_bytes(&self) -> u64 {
114        self.peak_bytes.load(Ordering::Relaxed)
115    }
116
117    pub fn host_attached_bytes(&self) -> u64 {
118        self.host_attached_bytes.load(Ordering::Relaxed)
119    }
120
121    pub fn host_attached_counter(&self) -> Arc<AtomicU64> {
122        Arc::clone(&self.host_attached_bytes)
123    }
124
125    /// Charges happen on the store thread, but cancelled Git workers can refund
126    /// their reservations concurrently during execution cleanup.
127    pub fn charge_host_bytes(&self, n: u64) -> Result<(), MemoryCapExceeded> {
128        self.host_attached_bytes
129            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
130                let next = current.checked_add(n)?;
131                (self.observed_bytes.saturating_add(next) <= self.max_total_bytes).then_some(next)
132            })
133            .map(|previous| {
134                self.peak_bytes.fetch_max(
135                    self.observed_bytes
136                        .saturating_add(previous)
137                        .saturating_add(n),
138                    Ordering::Relaxed,
139                );
140            })
141            .map_err(|current| MemoryCapExceeded {
142                requested: n,
143                already_observed: self.observed_bytes,
144                already_host_attached: current,
145                cap: self.max_total_bytes,
146            })
147    }
148
149    pub fn release_host_bytes(&self, n: u64) {
150        let _ = self.host_attached_bytes.fetch_update(
151            Ordering::Relaxed,
152            Ordering::Relaxed,
153            |current| Some(current.saturating_sub(n)),
154        );
155    }
156}
157
158impl ResourceLimiter for TenantLimits {
159    fn memory_growing(
160        &mut self,
161        current: usize,
162        desired: usize,
163        _maximum: Option<usize>,
164    ) -> wasmtime::Result<bool> {
165        let delta = (desired as u64).saturating_sub(current as u64);
166        let next = self.observed_bytes.saturating_add(delta);
167        // host_attached_bytes is read-only here; writes belong to charge/release_host_bytes.
168        let host = self.host_attached_bytes.load(Ordering::Relaxed);
169        if next.saturating_add(host) > self.max_total_bytes {
170            return Ok(false);
171        }
172        self.observed_bytes = next;
173        self.peak_bytes
174            .fetch_max(next.saturating_add(host), Ordering::Relaxed);
175        Ok(true)
176    }
177
178    fn table_growing(
179        &mut self,
180        _current: usize,
181        _desired: usize,
182        _maximum: Option<usize>,
183    ) -> wasmtime::Result<bool> {
184        Ok(true)
185    }
186}
187
188/// Call once, immediately after constructing the `Store`, before instantiating any module.
189pub fn install_tenant_limits(store: &mut Store<StoreData>) {
190    store.limiter(|data| &mut data.tenant_limits);
191}
192
193/// Usage survives successful execution, traps, and cleanup of host allocations.
194#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
195pub struct ExecutionUsage {
196    /// All fuel spent: `wasm_fuel + host_fuel`.
197    pub fuel: u64,
198    /// Fuel burnt by the program's own Wasm instructions.
199    pub wasm_fuel: u64,
200    /// Fuel charged by host functions for the work they did on the program's
201    /// behalf (see [`super::fuel::charge_host_fuel`]).
202    pub host_fuel: u64,
203    pub memory_peak: u64,
204}
205
206impl ExecutionUsage {
207    pub fn capture(store: &Store<StoreData>, initial_fuel: u64) -> wasmtime::Result<Self> {
208        let remaining = store.get_fuel()?;
209        let fuel = initial_fuel
210            .checked_sub(remaining)
211            .ok_or_else(|| wasmtime::Error::msg("remaining fuel exceeds initial budget"))?;
212        // Batched host charges the engine has not seen yet; a run that Wasm
213        // exhausted with a batch pending overran the budget by that much, and
214        // the report stays within the budget.
215        let fuel = fuel
216            .saturating_add(store.data().host_fuel_pending)
217            .min(initial_fuel);
218        let host_fuel = store.data().host_fuel;
219        let wasm_fuel = fuel
220            .checked_sub(host_fuel)
221            .ok_or_else(|| wasmtime::Error::msg("host fuel exceeds the fuel spent"))?;
222        Ok(Self {
223            fuel,
224            wasm_fuel,
225            host_fuel,
226            memory_peak: store.data().tenant_limits.peak_bytes(),
227        })
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn memory_peak_retains_released_host_allocations_and_excludes_refusals() {
237        let mut limits = TenantLimits::new(1000);
238        assert!(limits.memory_growing(0, 200, None).unwrap());
239        limits.charge_host_bytes(500).unwrap();
240        limits.release_host_bytes(500);
241        assert_eq!(limits.peak_bytes(), 700);
242        limits.charge_host_bytes(100).unwrap();
243        assert!(limits.memory_growing(200, 650, None).unwrap());
244        assert_eq!(limits.peak_bytes(), 750);
245        assert!(limits.charge_host_bytes(300).is_err());
246        assert!(!limits.memory_growing(650, 950, None).unwrap());
247        assert_eq!(limits.peak_bytes(), 750);
248    }
249}