Skip to main content

agentmux_common/
pagefile.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Windows page-file volume tracking, shared between `agentmux-srv`
5//! (`backend/sysinfo.rs`'s StatusBar telemetry) and `agentmux-cef`
6//! (`memory_heartbeat.rs`'s low-memory banner). Extracted from `sysinfo.rs`
7//! by `docs/specs/SPEC_RAM_PAGEFILE_PRESSURE_SPLIT_2026_08_07.md` §4 so the
8//! two processes can't independently drift on "what counts as system-managed"
9//! the way the commit-ratio classifier and the StatusBar gauge once did
10//! (issue #2218) — one implementation, two callers.
11//!
12//! Per `docs/specs/SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29.md` §5.2 P0: a
13//! system-managed page file wants to grow toward `min(3×RAM, ⅛ volume)` but
14//! can't if the volume it lives on doesn't have the free space. This module
15//! answers two questions: which volume backs the page file, and can Windows
16//! actually grow it there.
17
18#[cfg(target_os = "windows")]
19const BYTES_PER_GB: f64 = 1_073_741_824.0;
20
21/// One `PagingFiles` registry entry
22/// (`HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management`),
23/// parsed from its `"<path> <initial_mb> <maximum_mb>"` string form. Per
24/// [Microsoft's documented format](https://learn.microsoft.com/en-us/troubleshoot/windows-client/performance/how-to-determine-the-appropriate-page-file-size-for-64-bit-versions-of-windows):
25/// `initial_mb == 0 && maximum_mb == 0` means "system managed" (auto-grow)
26/// for that specific pagefile.
27#[cfg(any(target_os = "windows", test))]
28#[derive(Debug, Clone, PartialEq)]
29struct PagingFileEntry {
30    /// Drive letter the pagefile lives on, uppercased, e.g. `"C"`. `None` if
31    /// the path couldn't be parsed as `<letter>:\...` (UNC path, malformed).
32    drive_letter: Option<char>,
33    system_managed: bool,
34}
35
36/// Parse one raw `"<path> <initial_mb> <maximum_mb>"` line from the
37/// `PagingFiles` REG_MULTI_SZ value. Tolerant of the format's use of `??`
38/// instead of a drive letter for some `\\?\Volume{guid}\` forms — those
39/// yield `drive_letter: None` rather than erroring, since a pure parser
40/// should never fail on a value the OS itself produced.
41#[cfg(any(target_os = "windows", test))]
42fn parse_paging_file_entry(line: &str) -> Option<PagingFileEntry> {
43    let mut parts = line.split_whitespace();
44    let path = parts.next()?;
45    let initial: u64 = parts.next()?.parse().ok()?;
46    let maximum: u64 = parts.next()?.parse().ok()?;
47    let drive_letter = path
48        .chars()
49        .next()
50        .filter(|c| c.is_ascii_alphabetic())
51        .map(|c| c.to_ascii_uppercase());
52    Some(PagingFileEntry {
53        drive_letter,
54        system_managed: initial == 0 && maximum == 0,
55    })
56}
57
58/// Decide which volume to watch and whether it's at risk of a stuck
59/// (non-growing) page file, from the parsed `PagingFiles` entries.
60///
61/// - **Empty list** (the common default): Windows fully automatically
62///   manages a single pagefile's size AND location — not expressed as any
63///   registry entry at all. Falls back to `system_drive` as the practical
64///   answer (where CEF/the OS itself lives; the overwhelmingly likely
65///   pagefile location in this configuration) — `system_managed: true`.
66/// - **Has entries**: watch the first volume with a `system_managed: true`
67///   entry (the volume that can actually run out of the growth room this
68///   spec is about). If every entry is fixed-size, no volume is at growth
69///   risk from disk space — report `system_managed: false` on
70///   `system_drive` for visibility, but callers must not apply the
71///   "can't grow" risk framing to a fixed-size pagefile.
72#[cfg(any(target_os = "windows", test))]
73fn resolve_pagefile_watch_target(entries: &[PagingFileEntry], system_drive: char) -> (char, bool) {
74    if entries.is_empty() {
75        return (system_drive, true);
76    }
77    for e in entries {
78        if e.system_managed {
79            return (e.drive_letter.unwrap_or(system_drive), true);
80        }
81    }
82    (system_drive, false)
83}
84
85/// Read `PagingFiles` and split it into entries. A read failure returns an
86/// empty vec (caller's `resolve_pagefile_watch_target` then
87/// degrades to "watch the system drive as if system-managed" — the correct
88/// fail-open default: assume the common case rather than silently reporting
89/// nothing).
90#[cfg(target_os = "windows")]
91fn read_paging_files_registry() -> Vec<PagingFileEntry> {
92    use windows_sys::Win32::Foundation::ERROR_SUCCESS;
93    use windows_sys::Win32::System::Registry::{
94        RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_LOCAL_MACHINE, KEY_READ,
95        REG_MULTI_SZ,
96    };
97
98    let subkey: Vec<u16> =
99        "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Memory Management\0"
100            .encode_utf16()
101            .collect();
102    let value_name: Vec<u16> = "PagingFiles\0".encode_utf16().collect();
103
104    unsafe {
105        let mut hkey: HKEY = std::ptr::null_mut();
106        if RegOpenKeyExW(HKEY_LOCAL_MACHINE, subkey.as_ptr(), 0, KEY_READ, &mut hkey) as u32
107            != ERROR_SUCCESS
108        {
109            return Vec::new();
110        }
111
112        let mut buf_bytes: u32 = 0;
113        let mut value_type: u32 = 0;
114        let query1 = RegQueryValueExW(
115            hkey,
116            value_name.as_ptr(),
117            std::ptr::null(),
118            &mut value_type,
119            std::ptr::null_mut(),
120            &mut buf_bytes,
121        );
122        if query1 as u32 != ERROR_SUCCESS || value_type != REG_MULTI_SZ || buf_bytes == 0 {
123            RegCloseKey(hkey);
124            return Vec::new();
125        }
126
127        let mut buf: Vec<u16> = vec![0u16; buf_bytes as usize / 2];
128        let query2 = RegQueryValueExW(
129            hkey,
130            value_name.as_ptr(),
131            std::ptr::null(),
132            std::ptr::null_mut(),
133            buf.as_mut_ptr() as *mut u8,
134            &mut buf_bytes,
135        );
136        RegCloseKey(hkey);
137        if query2 as u32 != ERROR_SUCCESS {
138            return Vec::new();
139        }
140
141        // REG_MULTI_SZ: NUL-separated strings, terminated by a double NUL.
142        buf.split(|&c| c == 0)
143            .filter(|s| !s.is_empty())
144            .filter_map(|s| parse_paging_file_entry(&String::from_utf16_lossy(s)))
145            .collect()
146    }
147}
148
149/// Free/total space (GB) for a drive letter, e.g. `'C'`. Windows-only;
150/// returns `None` on any failure (drive not found, API error) so callers
151/// degrade to "no data" rather than a wrong number.
152#[cfg(target_os = "windows")]
153pub fn drive_free_total_gb(drive_letter: char) -> Option<(f64, f64)> {
154    use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
155    let root: Vec<u16> = format!("{}:\\\0", drive_letter).encode_utf16().collect();
156    let mut free_available: u64 = 0;
157    let mut total: u64 = 0;
158    let ok = unsafe {
159        GetDiskFreeSpaceExW(
160            root.as_ptr(),
161            &mut free_available,
162            &mut total,
163            std::ptr::null_mut(),
164        )
165    };
166    if ok == 0 {
167        return None;
168    }
169    Some((
170        free_available as f64 / BYTES_PER_GB,
171        total as f64 / BYTES_PER_GB,
172    ))
173}
174
175/// The `(drive_letter, system_managed)` pagefile watch target, resolved
176/// once per process lifetime. `PagingFiles` is machine configuration that
177/// requires a reboot to take effect — re-reading the registry on every
178/// caller's poll loop would be pure waste. Free disk space, by contrast,
179/// genuinely changes during a session — call `drive_free_total_gb` for that,
180/// as often as the caller's own poll cadence needs.
181#[cfg(target_os = "windows")]
182pub fn pagefile_watch_target() -> (char, bool) {
183    static TARGET: std::sync::OnceLock<(char, bool)> = std::sync::OnceLock::new();
184    *TARGET.get_or_init(|| {
185        let system_drive = std::env::var("SystemDrive")
186            .ok()
187            .and_then(|s| s.chars().next())
188            .map(|c| c.to_ascii_uppercase())
189            .unwrap_or('C');
190        let entries = read_paging_files_registry();
191        resolve_pagefile_watch_target(&entries, system_drive)
192    })
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn parses_system_managed_entry() {
201        let e = parse_paging_file_entry("C:\\pagefile.sys 0 0").unwrap();
202        assert_eq!(e.drive_letter, Some('C'));
203        assert!(e.system_managed);
204    }
205
206    #[test]
207    fn parses_fixed_size_entry() {
208        let e = parse_paging_file_entry("D:\\pagefile.sys 4096 8192").unwrap();
209        assert_eq!(e.drive_letter, Some('D'));
210        assert!(!e.system_managed);
211    }
212
213    #[test]
214    fn lowercases_drive_letter_normalized_to_upper() {
215        // Windows can report either case; the registry doesn't enforce one.
216        let e = parse_paging_file_entry("c:\\pagefile.sys 0 0").unwrap();
217        assert_eq!(e.drive_letter, Some('C'));
218    }
219
220    #[test]
221    fn malformed_line_returns_none() {
222        assert!(parse_paging_file_entry("").is_none());
223        assert!(parse_paging_file_entry("C:\\pagefile.sys").is_none());
224        assert!(parse_paging_file_entry("C:\\pagefile.sys notanumber 0").is_none());
225    }
226
227    #[test]
228    fn empty_entries_falls_back_to_system_drive_as_managed() {
229        // The common default: no explicit PagingFiles entries at all means
230        // Windows fully auto-manages a single pagefile — not expressed as a
231        // registry entry, so an empty list must NOT be read as "no pagefile
232        // risk" (the opposite of the truth).
233        let (drive, managed) = resolve_pagefile_watch_target(&[], 'C');
234        assert_eq!(drive, 'C');
235        assert!(managed);
236    }
237
238    #[test]
239    fn watches_the_first_system_managed_volume() {
240        let entries = vec![
241            PagingFileEntry { drive_letter: Some('D'), system_managed: false },
242            PagingFileEntry { drive_letter: Some('E'), system_managed: true },
243        ];
244        let (drive, managed) = resolve_pagefile_watch_target(&entries, 'C');
245        assert_eq!(drive, 'E');
246        assert!(managed);
247    }
248
249    #[test]
250    fn all_fixed_size_reports_not_managed_on_system_drive() {
251        // No volume is at "can't grow" risk — but we still surface a
252        // number (system drive) for visibility, correctly flagged as not
253        // subject to this specific risk.
254        let entries = vec![PagingFileEntry { drive_letter: Some('D'), system_managed: false }];
255        let (drive, managed) = resolve_pagefile_watch_target(&entries, 'C');
256        assert_eq!(drive, 'C');
257        assert!(!managed);
258    }
259}