oxibonsai_runtime/memory.rs
1//! Runtime memory profiling.
2//!
3//! Reads process RSS (Resident Set Size) on macOS (via Mach `task_info`) and
4//! Linux (via `/proc/self/statm`). Returns `0` on unsupported platforms.
5//!
6//! ## Usage
7//!
8//! ```
9//! use oxibonsai_runtime::memory::{get_rss_bytes, MemoryProfiler};
10//!
11//! let profiler = MemoryProfiler::new();
12//! let snapshot = profiler.sample();
13//! println!("RSS: {} bytes", snapshot.rss_bytes);
14//! println!("Peak RSS: {} bytes", profiler.peak_rss_bytes());
15//! ```
16
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::time::Instant;
19
20// ─── MemorySnapshot ─────────────────────────────────────────────────────────
21
22/// Memory snapshot at a point in time.
23#[derive(Debug, Clone)]
24pub struct MemorySnapshot {
25 /// Resident Set Size in bytes at the moment of sampling.
26 pub rss_bytes: u64,
27 /// Monotonic timestamp at which the snapshot was taken.
28 pub timestamp: Instant,
29 /// Milliseconds since the Unix epoch at the time of sampling.
30 ///
31 /// Derived from `std::time::SystemTime::now()` — zero on platforms where
32 /// `SystemTime` is unavailable (e.g., wasm32-unknown-unknown).
33 pub timestamp_ms: u64,
34}
35
36// ─── Public entry point ──────────────────────────────────────────────────────
37
38/// Get current process RSS (Resident Set Size) in bytes.
39///
40/// Returns `0` on unsupported platforms (WASM, Windows, etc.).
41/// On Linux reads `/proc/self/statm`; on macOS calls the Mach `task_info` API.
42pub fn get_rss_bytes() -> u64 {
43 platform::rss_bytes()
44}
45
46// ─── Platform implementations ────────────────────────────────────────────────
47
48#[cfg(target_os = "linux")]
49mod platform {
50 pub(super) fn rss_bytes() -> u64 {
51 rss_from_proc_statm().unwrap_or(0)
52 }
53
54 /// Parse `/proc/self/statm`.
55 ///
56 /// Line format: `size resident shared text lib data dt` — all in pages.
57 fn rss_from_proc_statm() -> Option<u64> {
58 let content = std::fs::read_to_string("/proc/self/statm").ok()?;
59 let resident_pages: u64 = content.split_whitespace().nth(1)?.parse().ok()?;
60 let page_size = page_size_bytes();
61 Some(resident_pages * page_size)
62 }
63
64 fn page_size_bytes() -> u64 {
65 // SAFETY: sysconf is always safe to call; negative return means error.
66 let ps = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
67 if ps > 0 {
68 ps as u64
69 } else {
70 4096
71 }
72 }
73}
74
75#[cfg(target_os = "macos")]
76mod platform {
77 pub(super) fn rss_bytes() -> u64 {
78 rss_from_mach().unwrap_or(0)
79 }
80
81 /// Query the Mach kernel for the task's resident private memory.
82 ///
83 /// Uses `task_info(TASK_VM_INFO)` via the `mach2` crate — a stable public
84 /// macOS API. The `mach2` crate is the recommended modern replacement for
85 /// the deprecated macOS bindings in `libc`.
86 fn rss_from_mach() -> Option<u64> {
87 use mach2::kern_return::KERN_SUCCESS;
88 use mach2::task::task_info;
89 use mach2::task_info::{task_flavor_t, task_info_t};
90 use mach2::traps::mach_task_self;
91
92 // Mach task_info flavor constants (from <mach/task_info.h>).
93 const TASK_VM_INFO: task_flavor_t = 22;
94
95 // Minimal layout of `task_vm_info_data_t`.
96 // Only `resident_size` (offset 24 bytes) is needed; the rest is zeroed.
97 // The struct is stable across macOS versions — it only grows at the end.
98 //
99 // Field layout (verified against macOS 10.x – 15.x SDK):
100 // virtual_size: u64 (offset 0)
101 // region_count: i32 (offset 8)
102 // page_size: i32 (offset 12)
103 // resident_size: u64 (offset 16)
104 // … 83 additional u64 fields (phys_footprint, etc.)
105 //
106 // Total: 87 natural_t (u32) words → TASK_VM_INFO_COUNT = 87.
107 const TASK_VM_INFO_COUNT: u32 = 87;
108
109 #[repr(C)]
110 struct TaskVmInfo {
111 virtual_size: u64,
112 region_count: i32,
113 page_size: i32,
114 resident_size: u64,
115 _rest: [u64; 83],
116 }
117
118 let mut info: TaskVmInfo = unsafe { std::mem::zeroed() };
119 let mut count: u32 = TASK_VM_INFO_COUNT;
120
121 // SAFETY: `task_info` is a stable Mach syscall. The buffer is zeroed,
122 // the flavor is TASK_VM_INFO, and `count` is set to the correct
123 // size in natural_t units.
124 let ret = unsafe {
125 task_info(
126 mach_task_self(),
127 TASK_VM_INFO,
128 &mut info as *mut TaskVmInfo as task_info_t,
129 &mut count as *mut u32,
130 )
131 };
132
133 if ret == KERN_SUCCESS {
134 Some(info.resident_size)
135 } else {
136 None
137 }
138 }
139}
140
141#[cfg(not(any(target_os = "linux", target_os = "macos")))]
142mod platform {
143 pub(super) fn rss_bytes() -> u64 {
144 0
145 }
146}
147
148// ─── MemoryProfiler ─────────────────────────────────────────────────────────
149
150/// Simple memory profiler that tracks peak RSS usage over its lifetime.
151///
152/// Designed to be shared via `Arc<MemoryProfiler>` across threads.
153/// All mutable state is stored in atomics, so no locking is required.
154///
155/// # Example
156///
157/// ```
158/// use oxibonsai_runtime::memory::MemoryProfiler;
159///
160/// let profiler = MemoryProfiler::new();
161///
162/// // Sample at some point during processing
163/// let snap = profiler.sample();
164/// println!("current RSS: {} bytes", snap.rss_bytes);
165///
166/// // Peak may differ from current if memory was freed
167/// println!("peak RSS: {} bytes", profiler.peak_rss_bytes());
168/// println!("delta: {} bytes", profiler.delta_bytes());
169/// ```
170#[derive(Debug)]
171pub struct MemoryProfiler {
172 /// RSS at profiler creation time.
173 start_rss: u64,
174 /// Highest observed RSS.
175 peak_rss: AtomicU64,
176 /// Number of times `sample()` has been called.
177 sample_count: AtomicU64,
178}
179
180impl MemoryProfiler {
181 /// Create a new profiler, recording the current RSS as the baseline.
182 pub fn new() -> Self {
183 let current = get_rss_bytes();
184 Self {
185 start_rss: current,
186 peak_rss: AtomicU64::new(current),
187 sample_count: AtomicU64::new(0),
188 }
189 }
190
191 /// Take a memory snapshot, updating the peak if necessary.
192 ///
193 /// Lock-free and safe to call from any thread.
194 pub fn sample(&self) -> MemorySnapshot {
195 let rss = get_rss_bytes();
196 self.peak_rss.fetch_max(rss, Ordering::Relaxed);
197 self.sample_count.fetch_add(1, Ordering::Relaxed);
198 let timestamp_ms = std::time::SystemTime::now()
199 .duration_since(std::time::UNIX_EPOCH)
200 .unwrap_or_default()
201 .as_millis() as u64;
202 MemorySnapshot {
203 rss_bytes: rss,
204 timestamp: Instant::now(),
205 timestamp_ms,
206 }
207 }
208
209 /// Highest RSS observed across all `sample()` calls and at creation.
210 pub fn peak_rss_bytes(&self) -> u64 {
211 self.peak_rss.load(Ordering::Relaxed)
212 }
213
214 /// RSS at the time this profiler was created.
215 pub fn start_rss_bytes(&self) -> u64 {
216 self.start_rss
217 }
218
219 /// Signed difference: `peak_rss − start_rss`.
220 ///
221 /// Positive means memory grew; negative (rare) means the OS reclaimed
222 /// pages between profiler creation and the peak sample.
223 pub fn delta_bytes(&self) -> i64 {
224 self.peak_rss_bytes() as i64 - self.start_rss as i64
225 }
226
227 /// Total number of `sample()` calls made on this profiler.
228 pub fn sample_count(&self) -> u64 {
229 self.sample_count.load(Ordering::Relaxed)
230 }
231
232 /// Take a memory snapshot, updating the peak if necessary.
233 ///
234 /// Alias for `sample` using the name required by the task specification.
235 pub fn take_snapshot(&self) -> MemorySnapshot {
236 self.sample()
237 }
238
239 /// Current RSS in bytes as `Option<u64>`.
240 ///
241 /// Returns `None` on platforms where RSS reading is unsupported (WASM, etc.).
242 /// On Linux and macOS this always returns `Some(value)`, where `value` may be
243 /// `0` only in the extremely unlikely case that the OS returns an error.
244 pub fn current_rss_bytes(&self) -> Option<u64> {
245 let rss = get_rss_bytes();
246 if rss == 0 {
247 #[cfg(any(target_os = "linux", target_os = "macos"))]
248 return Some(rss);
249 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
250 return None;
251 }
252 Some(rss)
253 }
254}
255
256impl Default for MemoryProfiler {
257 fn default() -> Self {
258 Self::new()
259 }
260}
261
262// ─── Tests ───────────────────────────────────────────────────────────────────
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn get_rss_returns_value() {
270 let rss = get_rss_bytes();
271 // On supported platforms (Linux, macOS) this should be > 0.
272 // On WASM / unsupported it returns 0 — both outcomes are valid;
273 // what matters is that the call does not panic.
274 #[cfg(any(target_os = "linux", target_os = "macos"))]
275 assert!(rss > 0, "RSS should be > 0 on Linux/macOS, got {rss}");
276 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
277 let _ = rss;
278 }
279
280 #[test]
281 fn memory_profiler_new_succeeds() {
282 let profiler = MemoryProfiler::new();
283 assert!(profiler.start_rss_bytes() < u64::MAX);
284 assert_eq!(profiler.sample_count(), 0);
285 }
286
287 #[test]
288 fn memory_profiler_sample_returns_snapshot() {
289 let profiler = MemoryProfiler::new();
290 let snap = profiler.sample();
291 assert_eq!(profiler.sample_count(), 1);
292 let _ = snap.rss_bytes;
293 }
294
295 #[test]
296 fn memory_profiler_peak_ge_start_after_sampling() {
297 let profiler = MemoryProfiler::new();
298 profiler.sample();
299 profiler.sample();
300 profiler.sample();
301 assert!(
302 profiler.peak_rss_bytes() >= profiler.start_rss_bytes(),
303 "peak ({}) must be >= start ({})",
304 profiler.peak_rss_bytes(),
305 profiler.start_rss_bytes()
306 );
307 }
308
309 #[test]
310 fn memory_profiler_delta_does_not_panic() {
311 let profiler = MemoryProfiler::new();
312 let _v: Vec<u8> = vec![0u8; 1024 * 1024]; // allocate 1 MiB
313 profiler.sample();
314 // delta can be >= or < 0 depending on OS; we just ensure no panic.
315 let _ = profiler.delta_bytes();
316 }
317
318 #[test]
319 fn memory_profiler_sample_count_increments() {
320 let profiler = MemoryProfiler::new();
321 assert_eq!(profiler.sample_count(), 0);
322 for i in 1..=5 {
323 profiler.sample();
324 assert_eq!(profiler.sample_count(), i);
325 }
326 }
327
328 #[test]
329 fn memory_profiler_default_equals_new() {
330 let p = MemoryProfiler::default();
331 assert_eq!(p.sample_count(), 0);
332 }
333
334 // ── Task-spec required test names ────────────────────────────────────────
335
336 /// Verify that `get_rss_bytes()` returns a non-zero value on supported platforms.
337 #[test]
338 fn test_get_rss_returns_nonzero() {
339 let rss = get_rss_bytes();
340 #[cfg(any(target_os = "linux", target_os = "macos"))]
341 assert!(
342 rss > 0,
343 "get_rss_bytes() should return > 0 on Linux/macOS, got {rss}"
344 );
345 // On other platforms (e.g., wasm32) we allow 0 — but the call must not panic.
346 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
347 let _ = rss;
348 }
349
350 /// Verify that the profiler's peak tracks the highest observed RSS.
351 #[test]
352 fn test_profiler_peak_tracks_correctly() {
353 let profiler = MemoryProfiler::new();
354
355 // Take several snapshots; peak must be >= all observed rss values.
356 let s1 = profiler.take_snapshot();
357 let s2 = profiler.take_snapshot();
358 let s3 = profiler.take_snapshot();
359
360 let max_observed = s1.rss_bytes.max(s2.rss_bytes).max(s3.rss_bytes);
361 let peak = profiler.peak_rss_bytes();
362
363 assert!(
364 peak >= max_observed,
365 "peak ({peak}) must be >= max observed rss ({max_observed})"
366 );
367 }
368
369 /// Verify that each snapshot carries a valid timestamp_ms.
370 #[test]
371 fn test_snapshot_has_timestamp() {
372 let profiler = MemoryProfiler::new();
373 let snap = profiler.take_snapshot();
374
375 // timestamp_ms must be a plausible Unix epoch millisecond value.
376 // 2020-01-01 = 1577836800000 ms; 2100-01-01 ≈ 4102444800000 ms.
377 const EPOCH_2020: u64 = 1_577_836_800_000;
378 const EPOCH_2100: u64 = 4_102_444_800_000;
379
380 assert!(
381 snap.timestamp_ms >= EPOCH_2020,
382 "timestamp_ms ({}) should be >= 2020 epoch ({EPOCH_2020})",
383 snap.timestamp_ms
384 );
385 assert!(
386 snap.timestamp_ms <= EPOCH_2100,
387 "timestamp_ms ({}) should be <= 2100 epoch ({EPOCH_2100})",
388 snap.timestamp_ms
389 );
390 }
391}