Skip to main content

running_process/broker/
fs_health.rs

1//! Filesystem-health probes for status/doctor visibility (#390).
2//!
3//! Inode usage matters on Unix filesystems with fixed inode tables
4//! (ext4 most prominently): the daemon data dir can fail writes with
5//! ENOSPC while plenty of bytes remain free. Windows filesystems have no
6//! fixed inode table, so the probe reports "not applicable" there instead
7//! of faking numbers.
8
9use std::path::Path;
10
11/// Inode totals for one filesystem, from `statvfs` on Unix.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub struct InodeUsage {
14    /// Total inodes on the filesystem (`f_files`).
15    pub total: u64,
16    /// Inodes available to unprivileged users (`f_favail`).
17    pub free: u64,
18}
19
20impl InodeUsage {
21    /// Inodes currently in use.
22    pub fn used(&self) -> u64 {
23        self.total.saturating_sub(self.free)
24    }
25
26    /// Used fraction in `[0.0, 1.0]`; `0.0` when the total is zero.
27    pub fn used_ratio(&self) -> f64 {
28        if self.total == 0 {
29            0.0
30        } else {
31            self.used() as f64 / self.total as f64
32        }
33    }
34}
35
36/// Probe inode usage for the filesystem containing `path`.
37///
38/// Returns `Ok(None)` when inode accounting does not apply: always on
39/// Windows, and on Unix filesystems that report a zero inode table
40/// (e.g. btrfs). Errors are real probe failures (missing path, EACCES).
41pub fn inode_usage(path: &Path) -> std::io::Result<Option<InodeUsage>> {
42    #[cfg(windows)]
43    {
44        let _ = path;
45        Ok(None)
46    }
47    #[cfg(unix)]
48    {
49        use std::os::unix::ffi::OsStrExt;
50
51        let bytes = path.as_os_str().as_bytes();
52        let c_path = std::ffi::CString::new(bytes)
53            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
54        let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
55        let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stats) };
56        if rc != 0 {
57            return Err(std::io::Error::last_os_error());
58        }
59        if stats.f_files == 0 {
60            return Ok(None);
61        }
62        // fsfilcnt_t is u64 on Linux but u32 on macOS; keep explicit casts.
63        #[allow(clippy::unnecessary_cast)]
64        let usage = InodeUsage {
65            total: stats.f_files as u64,
66            free: stats.f_favail as u64,
67        };
68        Ok(Some(usage))
69    }
70}
71
72/// Probe inode usage for the daemon data directory (where the SQLite
73/// tracking database lives), walking up to the nearest existing ancestor
74/// so the probe stays read-only even before the daemon ever ran.
75pub fn daemon_data_dir_inode_usage() -> std::io::Result<Option<InodeUsage>> {
76    let dir = crate::client::paths::data_dir();
77    let mut probe: &Path = &dir;
78    while !probe.exists() {
79        match probe.parent() {
80            Some(parent) => probe = parent,
81            None => break,
82        }
83    }
84    inode_usage(probe)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn used_ratio_handles_zero_total() {
93        let usage = InodeUsage { total: 0, free: 0 };
94        assert_eq!(usage.used_ratio(), 0.0);
95    }
96
97    #[test]
98    fn used_ratio_is_fractional() {
99        let usage = InodeUsage {
100            total: 100,
101            free: 25,
102        };
103        assert_eq!(usage.used(), 75);
104        assert!((usage.used_ratio() - 0.75).abs() < f64::EPSILON);
105    }
106
107    #[cfg(unix)]
108    #[test]
109    fn inode_usage_probes_temp_dir() {
110        let result = inode_usage(&std::env::temp_dir()).expect("statvfs on temp dir");
111        if let Some(usage) = result {
112            assert!(usage.total > 0);
113            assert!(usage.free <= usage.total);
114        }
115    }
116
117    #[cfg(windows)]
118    #[test]
119    fn inode_usage_is_not_applicable_on_windows() {
120        let result = inode_usage(&std::env::temp_dir()).expect("probe never fails on windows");
121        assert_eq!(result, None);
122    }
123
124    #[test]
125    fn daemon_data_dir_probe_never_panics() {
126        let _ = daemon_data_dir_inode_usage();
127    }
128}