Skip to main content

ashlar_native/
memory.rs

1//! How much memory this process uses, and giving freed memory back to the system.
2//!
3//! [`process_memory`] reads the numbers the OS's own tools show: on macOS the *footprint*
4//! (Activity Monitor's "Memory" column, `footprint <pid>`: dirty private memory including
5//! compressed and GPU memory the process owns) and the resident size; on Linux the private
6//! resident plus swapped memory and the resident size (`/proc/self/status`); on Windows the
7//! private commit and the working set. Where the OS keeps it, it also gives the highest footprint
8//! since the process started, so a short spike between two readings still counts.
9//! [`thread_count`] says how many threads the process runs.
10//!
11//! [`release_free_memory`] asks the allocator to return pages it holds free. Where that works
12//! (glibc's `malloc_trim`) it shrinks the process at once. On macOS it does nothing: the system
13//! call for it (`malloc_zone_pressure_relief`) releases next to
14//! nothing (measured: 0–49 KB after freeing gigabytes): the default allocator keeps freed small
15//! and medium regions dirty for reuse and the kernel takes them back by itself over the next
16//! 30–60 s. A process started with `MallocSpaceEfficient=1` in its environment hands freed
17//! regions back at once (a bundled app sets it through `LSEnvironment` in its Info.plist);
18//! [`space_efficient_malloc`] says whether this one was. Call it after dropping caches, not per
19//! frame.
20//!
21//! | | macOS | Linux | Windows | Web |
22//! |---|---|---|---|---|
23//! | [`process_memory`] | `proc_pid_rusage` | `/proc/self/status` | `GetProcessMemoryInfo` | `None` |
24//! | peak | lifetime max footprint | – (`None`) | `PeakPagefileUsage` | `None` |
25//! | [`thread_count`] | `proc_pidinfo` | `/proc/self/status` | toolhelp snapshot | `None` |
26//! | [`release_free_memory`] | no-op (see above) | `malloc_trim` (glibc) | no-op | no-op |
27
28use serde::Serialize;
29
30/// Memory of this process, in bytes.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct ProcessMemory {
34    /// What the system charges the process for: macOS phys_footprint, Linux private resident +
35    /// swap (`RssAnon + VmSwap`), Windows private commit (`PrivateUsage`).
36    pub footprint: u64,
37    /// Pages resident in RAM right now, shared ones included (RSS / working set).
38    pub resident: u64,
39    /// The highest `footprint` since the process started, where the OS keeps it: macOS
40    /// `ri_lifetime_max_phys_footprint`, Windows peak private commit (`PeakPagefileUsage`).
41    /// `None` on Linux, which keeps no peak of the same measure.
42    pub peak: Option<u64>,
43}
44
45/// This process's memory, or `None` where the platform has no way to ask (the web) or the call
46/// failed.
47pub fn process_memory() -> Option<ProcessMemory> {
48    imp::process_memory()
49}
50
51/// How many threads this process runs, or `None` where the platform has no way to ask (the web)
52/// or the call failed.
53pub fn thread_count() -> Option<u64> {
54    imp::thread_count()
55}
56
57/// Return the allocator's free pages to the system where the allocator can (glibc's
58/// `malloc_trim`). Returns the bytes released where the allocator says; 0 everywhere today (macOS
59/// has nothing that works, see the module docs; glibc does not say).
60pub fn release_free_memory() -> u64 {
61    imp::release_free_memory()
62}
63
64/// Whether macOS's allocator runs in its space-efficient mode (`MallocSpaceEfficient=1` in the
65/// environment the process started with), which returns freed memory to the system at once.
66/// Always false elsewhere.
67pub fn space_efficient_malloc() -> bool {
68    cfg!(target_os = "macos") && std::env::var_os("MallocSpaceEfficient").is_some_and(|v| v != "0" && !v.is_empty())
69}
70
71#[cfg(target_os = "macos")]
72#[allow(unsafe_code)]
73mod imp {
74    use super::ProcessMemory;
75
76    pub fn process_memory() -> Option<ProcessMemory> {
77        let mut info = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
78        // SAFETY: `info` is a properly aligned, writable `rusage_info_v4`, the struct the
79        // RUSAGE_INFO_V4 flavor fills (macOS 10.14 and later); the kernel writes at most its size. The header declares the
80        // buffer as `rusage_info_t *` (void **) but takes the struct's address, as Apple's own
81        // callers do.
82        let r = unsafe {
83            libc::proc_pid_rusage(std::process::id() as libc::c_int, libc::RUSAGE_INFO_V4, info.as_mut_ptr().cast::<libc::rusage_info_t>())
84        };
85        if r != 0 {
86            return None;
87        }
88        // SAFETY: the call succeeded and filled the struct; every field is a plain integer, for
89        // which the zeroed start was already a valid value.
90        let info = unsafe { info.assume_init() };
91        Some(ProcessMemory {
92            footprint: info.ri_phys_footprint,
93            resident: info.ri_resident_size,
94            peak: Some(info.ri_lifetime_max_phys_footprint.max(info.ri_phys_footprint)),
95        })
96    }
97
98    pub fn thread_count() -> Option<u64> {
99        let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::zeroed();
100        let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_taskinfo>()).ok()?;
101        // SAFETY: `info` is a properly aligned, writable `proc_taskinfo` of `size` bytes, the
102        // struct the PROC_PIDTASKINFO flavor fills; the kernel writes at most `size` bytes and
103        // returns how many it wrote.
104        let n = unsafe { libc::proc_pidinfo(std::process::id() as libc::c_int, libc::PROC_PIDTASKINFO, 0, info.as_mut_ptr().cast(), size) };
105        if n != size {
106            return None;
107        }
108        // SAFETY: the call filled all `size` bytes; every field is a plain integer, for which the
109        // zeroed start was already a valid value.
110        let info = unsafe { info.assume_init() };
111        u64::try_from(info.pti_threadnum).ok()
112    }
113
114    /// `malloc_zone_pressure_relief` was measured to release next to nothing on macOS 26 (0–49 KB
115    /// after freeing gigabytes), so there is no call here; see the module docs.
116    pub fn release_free_memory() -> u64 {
117        0
118    }
119}
120
121#[cfg(target_os = "linux")]
122#[allow(unsafe_code)]
123mod imp {
124    use super::ProcessMemory;
125
126    pub fn process_memory() -> Option<ProcessMemory> {
127        parse_status(&std::fs::read_to_string("/proc/self/status").ok()?)
128    }
129
130    /// `RssAnon`, `VmSwap` and `VmRSS` of a `/proc/<pid>/status` text (kB values).
131    pub(super) fn parse_status(text: &str) -> Option<ProcessMemory> {
132        let field = |name: &str| -> Option<u64> {
133            let line = text.lines().find(|l| l.starts_with(name) && l.as_bytes().get(name.len()) == Some(&b':'))?;
134            let kb: u64 = line.get(name.len() + 1..)?.trim().trim_end_matches("kB").trim().parse().ok()?;
135            Some(kb.saturating_mul(1024))
136        };
137        let resident = field("VmRSS")?;
138        let footprint = field("RssAnon").map_or(resident, |anon| anon.saturating_add(field("VmSwap").unwrap_or(0)));
139        Some(ProcessMemory { footprint, resident, peak: None })
140    }
141
142    pub fn thread_count() -> Option<u64> {
143        super::threads_in_status(&std::fs::read_to_string("/proc/self/status").ok()?)
144    }
145
146    pub fn release_free_memory() -> u64 {
147        #[cfg(target_env = "gnu")]
148        {
149            // SAFETY: malloc_trim only returns free heap pages to the kernel; 0 = keep no padding.
150            unsafe { libc::malloc_trim(0) };
151        }
152        0
153    }
154}
155
156#[cfg(target_os = "windows")]
157#[allow(unsafe_code)]
158mod imp {
159    use super::ProcessMemory;
160    use windows::Win32::Foundation::CloseHandle;
161    use windows::Win32::System::Diagnostics::ToolHelp::{
162        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
163    };
164    use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX};
165    use windows::Win32::System::Threading::GetCurrentProcess;
166
167    pub fn process_memory() -> Option<ProcessMemory> {
168        let mut c = PROCESS_MEMORY_COUNTERS_EX { cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32, ..Default::default() };
169        // SAFETY: GetCurrentProcess returns a pseudo handle that needs no closing; `c` is a
170        // writable PROCESS_MEMORY_COUNTERS_EX whose `cb` says its size, which the call accepts in
171        // place of the smaller PROCESS_MEMORY_COUNTERS it is declared with.
172        let ok = unsafe { GetProcessMemoryInfo(GetCurrentProcess(), (&raw mut c).cast::<PROCESS_MEMORY_COUNTERS>(), c.cb) };
173        ok.ok()?;
174        let footprint = c.PrivateUsage as u64;
175        Some(ProcessMemory { footprint, resident: c.WorkingSetSize as u64, peak: Some((c.PeakPagefileUsage as u64).max(footprint)) })
176    }
177
178    /// Walks a toolhelp snapshot of the processes to this one's entry (its `cntThreads`).
179    pub fn thread_count() -> Option<u64> {
180        // SAFETY: a process snapshot takes no pointers; the handle is closed below.
181        let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }.ok()?;
182        let me = std::process::id();
183        let mut entry = PROCESSENTRY32W { dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32, ..Default::default() };
184        // SAFETY: `snap` is the live snapshot from above and `entry` a writable PROCESSENTRY32W
185        // whose `dwSize` says its size, as both calls require.
186        let mut more = unsafe { Process32FirstW(snap, &raw mut entry) }.is_ok();
187        let mut found = None;
188        // bounded: a snapshot lists every process once, far fewer than this
189        for _ in 0..1_000_000 {
190            if !more {
191                break;
192            }
193            if entry.th32ProcessID == me {
194                found = Some(u64::from(entry.cntThreads));
195                break;
196            }
197            // SAFETY: as for Process32FirstW above.
198            more = unsafe { Process32NextW(snap, &raw mut entry) }.is_ok();
199        }
200        // SAFETY: `snap` is a handle this function owns and closes once.
201        let _ = unsafe { CloseHandle(snap) };
202        found
203    }
204
205    pub fn release_free_memory() -> u64 {
206        0
207    }
208}
209
210#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
211mod imp {
212    use super::ProcessMemory;
213
214    pub fn process_memory() -> Option<ProcessMemory> {
215        None
216    }
217
218    pub fn thread_count() -> Option<u64> {
219        None
220    }
221
222    pub fn release_free_memory() -> u64 {
223        0
224    }
225}
226
227/// The `Threads:` field of a `/proc/<pid>/status` text.
228#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
229fn threads_in_status(text: &str) -> Option<u64> {
230    text.lines().find_map(|l| l.strip_prefix("Threads:")).and_then(|v| v.trim().parse().ok())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
238    #[test]
239    fn the_footprint_counts_touched_memory() {
240        let before = process_memory().expect("process memory");
241        assert!(before.footprint > 0 && before.resident > 0, "{before:?}");
242        // 256 MB in 1 MB blocks, every page written
243        let blocks: Vec<Vec<u8>> = (0..256).map(|_| vec![1u8; 1 << 20]).collect();
244        let full = process_memory().expect("process memory");
245        assert!(full.footprint >= before.footprint + (200 << 20), "{before:?} → {full:?}");
246        drop(blocks);
247        // Whether freed pages leave at once is the allocator's business (macOS keeps them for
248        // up to a minute unless MallocSpaceEfficient=1): only the call itself is checked here.
249        release_free_memory();
250        assert!(process_memory().is_some());
251    }
252
253    #[cfg(all(target_os = "linux", target_env = "gnu"))]
254    #[test]
255    fn malloc_trim_hands_freed_heap_back() {
256        // 200 MB in 100 KB blocks: under glibc's mmap threshold, so they live on the heap
257        let blocks: Vec<Vec<u8>> = (0..2048).map(|_| vec![1u8; 100 << 10]).collect();
258        let full = process_memory().expect("process memory");
259        drop(blocks);
260        release_free_memory();
261        let after = process_memory().expect("process memory");
262        assert!(after.footprint + (150 << 20) <= full.footprint, "{full:?} → {after:?}");
263    }
264
265    #[cfg(any(target_os = "macos", target_os = "windows"))]
266    #[test]
267    fn the_os_peak_outlives_a_freed_spike() {
268        let before = process_memory().expect("process memory");
269        let peak_before = before.peak.expect("peak");
270        assert!(peak_before >= before.footprint, "{before:?}");
271        // a 128 MB spike, written, then freed before the next reading
272        let blocks: Vec<Vec<u8>> = (0..128).map(|_| vec![1u8; 1 << 20]).collect();
273        let during = process_memory().expect("process memory");
274        drop(blocks);
275        let after = process_memory().expect("process memory");
276        let peak = after.peak.expect("peak");
277        assert!(peak >= during.footprint.max(peak_before), "{before:?} {during:?} {after:?}");
278        assert!(peak >= before.footprint + (100 << 20), "{before:?} → {after:?}");
279    }
280
281    #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
282    #[test]
283    fn threads_are_counted() {
284        let before = thread_count().expect("thread count");
285        assert!(before >= 1);
286        let (tx, rx) = std::sync::mpsc::channel::<()>();
287        let (ready_tx, ready_rx) = std::sync::mpsc::channel::<()>();
288        let t = std::thread::spawn(move || {
289            let _ = ready_tx.send(());
290            let _ = rx.recv();
291        });
292        let _ = ready_rx.recv();
293        // other tests run threads in parallel: only "at least one more than zero" is stable
294        let during = thread_count().expect("thread count");
295        assert!(during >= 2, "{before} → {during}");
296        let _ = tx.send(());
297        let _ = t.join();
298    }
299
300    #[test]
301    fn proc_status_threads_parse() {
302        assert_eq!(threads_in_status("Name:\tx\nThreads:\t7\nVmRSS: 1 kB\n"), Some(7));
303        assert_eq!(threads_in_status("Name: x\n"), None);
304    }
305
306    #[test]
307    fn the_space_efficient_mode_follows_the_environment() {
308        let set = std::env::var_os("MallocSpaceEfficient").is_some_and(|v| v != "0" && !v.is_empty());
309        assert_eq!(space_efficient_malloc(), set && cfg!(target_os = "macos"));
310    }
311
312    #[cfg(target_os = "linux")]
313    #[test]
314    fn linux_status_is_parsed() {
315        let text = "Name:\tx\nVmRSS:\t  2048 kB\nRssAnon:\t  1024 kB\nVmSwap:\t     4 kB\n";
316        assert_eq!(imp::parse_status(text), Some(ProcessMemory { footprint: 1028 * 1024, resident: 2048 * 1024, peak: None }));
317        assert_eq!(imp::parse_status("VmRSS: x kB"), None);
318    }
319}