Skip to main content

mockforge_plugin_loader/
memory_tracking.rs

1//! Memory tracking and enforcement for the WASM sandbox.
2//!
3//! [`MemoryTracker`] implements Wasmtime's [`ResourceLimiter`] trait so
4//! the host can deny linear-memory growth that would exceed the
5//! configured cap, and observe the per-store peak. Wire it into a
6//! `Store<T>` by holding the tracker inside the store's data type and
7//! installing the limiter:
8//!
9//! ```ignore
10//! struct StoreData {
11//!     wasi: WasiCtx,
12//!     tracker: MemoryTracker,
13//! }
14//!
15//! let mut store = Store::new(&engine, StoreData {
16//!     wasi: wasi_ctx,
17//!     tracker: MemoryTracker::with_byte_limit(10 * 1024 * 1024),
18//! });
19//! store.limiter(|d| &mut d.tracker as &mut dyn wasmtime::ResourceLimiter);
20//! ```
21//!
22//! Modern Wasmtime (≥27) removed the standalone `Store::set_limits`
23//! API in favor of the closure-based `Store::limiter`, which is why
24//! the limiter must live inside the store's data type.
25
26use wasmtime::ResourceLimiter;
27
28/// Memory tracker for a single Wasmtime `Store`.
29///
30/// One tracker per store. The `Store::limiter` closure should hand
31/// back `&mut self` so Wasmtime can call `memory_growing` /
32/// `table_growing` for resource accounting.
33pub struct MemoryTracker {
34    /// Maximum memory allowed (bytes).
35    max_memory_bytes: usize,
36    /// Current memory usage (bytes), as reported by the most recent
37    /// `memory_growing` callback.
38    current_memory_bytes: usize,
39    /// Peak memory ever observed across the lifetime of this store.
40    /// Linear memory only grows in WASM, so this is monotonically
41    /// non-decreasing.
42    peak_memory_bytes: usize,
43    /// Maximum tables allowed.
44    max_tables: usize,
45    /// Current table count.
46    current_tables: usize,
47}
48
49impl MemoryTracker {
50    /// Create a tracker with a megabyte-precision cap.
51    pub fn new(max_memory_mb: usize) -> Self {
52        Self::with_byte_limit(max_memory_mb * 1024 * 1024)
53    }
54
55    /// Create a tracker with a byte-precision cap. Use this when the
56    /// caller already has a value in bytes (e.g. from
57    /// `ExecutionLimits::max_memory_bytes`) so the conversion can be
58    /// exact.
59    pub fn with_byte_limit(max_memory_bytes: usize) -> Self {
60        Self {
61            max_memory_bytes,
62            current_memory_bytes: 0,
63            peak_memory_bytes: 0,
64            max_tables: 10,
65            current_tables: 0,
66        }
67    }
68
69    /// Current linear-memory size in bytes.
70    pub fn current_memory(&self) -> usize {
71        self.current_memory_bytes
72    }
73
74    /// Highest linear-memory size observed in this store's lifetime.
75    pub fn peak_memory(&self) -> usize {
76        self.peak_memory_bytes
77    }
78
79    /// Configured upper bound in bytes.
80    pub fn max_memory(&self) -> usize {
81        self.max_memory_bytes
82    }
83
84    /// Memory usage as a percentage of the configured limit.
85    pub fn memory_usage_percent(&self) -> f64 {
86        if self.max_memory_bytes == 0 {
87            0.0
88        } else {
89            (self.current_memory_bytes as f64 / self.max_memory_bytes as f64) * 100.0
90        }
91    }
92
93    /// Whether the most recent observation exceeded the cap.
94    pub fn is_memory_exceeded(&self) -> bool {
95        self.current_memory_bytes > self.max_memory_bytes
96    }
97
98    fn update_peak(&mut self) {
99        if self.current_memory_bytes > self.peak_memory_bytes {
100            self.peak_memory_bytes = self.current_memory_bytes;
101        }
102    }
103}
104
105impl ResourceLimiter for MemoryTracker {
106    fn memory_growing(
107        &mut self,
108        current: usize,
109        desired: usize,
110        _maximum: Option<usize>,
111    ) -> anyhow::Result<bool> {
112        if desired > self.max_memory_bytes {
113            tracing::warn!(
114                "Memory growth denied: {} bytes requested, {} bytes allowed",
115                desired,
116                self.max_memory_bytes
117            );
118            return Ok(false);
119        }
120
121        self.current_memory_bytes = desired;
122        self.update_peak();
123
124        tracing::debug!(
125            "Memory growth allowed: {} -> {} bytes ({:.1}% of limit)",
126            current,
127            desired,
128            self.memory_usage_percent()
129        );
130
131        Ok(true)
132    }
133
134    fn table_growing(
135        &mut self,
136        _current: usize,
137        _desired: usize,
138        _maximum: Option<usize>,
139    ) -> anyhow::Result<bool> {
140        if self.current_tables >= self.max_tables {
141            tracing::warn!(
142                "Table creation denied: {} tables exist, {} allowed",
143                self.current_tables,
144                self.max_tables
145            );
146            return Ok(false);
147        }
148
149        self.current_tables += 1;
150        Ok(true)
151    }
152
153    fn tables(&self) -> usize {
154        self.current_tables
155    }
156
157    fn memories(&self) -> usize {
158        // We allow one memory instance per plugin sandbox.
159        1
160    }
161}
162
163/// Snapshot of a tracker's state. Cheap to clone for emitting on the
164/// invocation metrics bus or returning from a status endpoint.
165#[derive(Debug, Clone)]
166pub struct MemoryStats {
167    /// Current linear-memory size in bytes.
168    pub current_bytes: usize,
169    /// Peak linear-memory size observed in this store's lifetime.
170    pub peak_bytes: usize,
171    /// Configured upper bound in bytes.
172    pub limit_bytes: usize,
173    /// Current usage as a percentage of the configured limit.
174    pub usage_percent: f64,
175}
176
177impl MemoryStats {
178    /// Capture a snapshot of the current tracker state.
179    pub fn from_tracker(tracker: &MemoryTracker) -> Self {
180        Self {
181            current_bytes: tracker.current_memory(),
182            peak_bytes: tracker.peak_memory(),
183            limit_bytes: tracker.max_memory(),
184            usage_percent: tracker.memory_usage_percent(),
185        }
186    }
187
188    /// Usage above 90% of the limit.
189    pub fn is_critical(&self) -> bool {
190        self.usage_percent > 90.0
191    }
192
193    /// Usage above 75% of the limit.
194    pub fn is_high(&self) -> bool {
195        self.usage_percent > 75.0
196    }
197
198    /// Human-readable summary for log lines and the admin UI.
199    pub fn summary(&self) -> String {
200        format!(
201            "{} / {} MB ({:.1}%), peak: {} MB",
202            self.current_bytes / (1024 * 1024),
203            self.limit_bytes / (1024 * 1024),
204            self.usage_percent,
205            self.peak_bytes / (1024 * 1024)
206        )
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_memory_tracker_creation() {
216        let tracker = MemoryTracker::new(10);
217        assert_eq!(tracker.current_memory(), 0);
218        assert_eq!(tracker.peak_memory(), 0);
219        assert_eq!(tracker.max_memory(), 10 * 1024 * 1024);
220        assert_eq!(tracker.memory_usage_percent(), 0.0);
221    }
222
223    #[test]
224    fn test_with_byte_limit_is_exact() {
225        let tracker = MemoryTracker::with_byte_limit(5_242_880); // exact 5 MiB
226        assert_eq!(tracker.max_memory(), 5_242_880);
227    }
228
229    #[test]
230    fn test_memory_tracker_growing() {
231        let mut tracker = MemoryTracker::new(10);
232
233        let ok = tracker.memory_growing(0, 5 * 1024 * 1024, None).unwrap();
234        assert!(ok);
235        assert_eq!(tracker.current_memory(), 5 * 1024 * 1024);
236        assert_eq!(tracker.peak_memory(), 5 * 1024 * 1024);
237
238        let denied = tracker.memory_growing(5 * 1024 * 1024, 15 * 1024 * 1024, None).unwrap();
239        assert!(!denied);
240    }
241
242    #[test]
243    fn test_memory_stats() {
244        let mut tracker = MemoryTracker::new(10);
245        tracker.memory_growing(0, 8 * 1024 * 1024, None).unwrap();
246
247        let stats = MemoryStats::from_tracker(&tracker);
248        assert_eq!(stats.current_bytes, 8 * 1024 * 1024);
249        assert_eq!(stats.limit_bytes, 10 * 1024 * 1024);
250        assert!(stats.is_high());
251        assert!(!stats.is_critical());
252    }
253
254    #[test]
255    fn test_memory_tracker_peak() {
256        let mut tracker = MemoryTracker::new(10);
257
258        tracker.memory_growing(0, 5 * 1024 * 1024, None).unwrap();
259        assert_eq!(tracker.peak_memory(), 5 * 1024 * 1024);
260
261        tracker.memory_growing(5 * 1024 * 1024, 8 * 1024 * 1024, None).unwrap();
262        assert_eq!(tracker.peak_memory(), 8 * 1024 * 1024);
263
264        // Peak persists even after a hypothetical shrink (linear memory
265        // doesn't actually shrink in WASM, but the invariant still holds
266        // if a future Wasmtime version added shrink callbacks).
267        tracker.current_memory_bytes = 6 * 1024 * 1024;
268        assert_eq!(tracker.peak_memory(), 8 * 1024 * 1024);
269    }
270
271    #[test]
272    fn test_table_limits() {
273        let mut tracker = MemoryTracker::new(10);
274        tracker.max_tables = 2;
275
276        assert!(tracker.table_growing(0, 1, None).unwrap());
277        assert_eq!(tracker.current_tables, 1);
278
279        assert!(tracker.table_growing(1, 2, None).unwrap());
280        assert_eq!(tracker.current_tables, 2);
281
282        assert!(!tracker.table_growing(2, 3, None).unwrap());
283    }
284}