Skip to main content

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}