Skip to main content

corescout_substrate/observation/
source.rs

1//! Reading kernel text interfaces.
2//!
3//! Every sensor funnels its file access through here, for two reasons.
4//!
5//! **Absence is normal.** A missing `cpufreq` directory, an unreadable
6//! `energy_uj`, a `thermal_zone` that vanished on hotplug: none of these are
7//! errors, they are facts about the machine, and the correct response is an
8//! unobserved cell rather than a failed observation pass. So these helpers
9//! return `Option` and never propagate an error upward.
10//!
11//! **Normalisation happens once.** The whole point of the self-state plane is
12//! that consumers do not each reparse `/proc`. The parsing lives here, runs once
13//! per tick in one process, and everyone else reads numbers.
14
15use std::path::Path;
16
17use corescout_mirror::schema::Availability;
18use corescout_mirror::state::ChannelId;
19
20use crate::observation::{SensorOutcome, StateWriter};
21
22/// Read a file, trimming trailing whitespace. `None` if it cannot be read.
23pub fn string(path: impl AsRef<Path>) -> Option<String> {
24    std::fs::read_to_string(path)
25        .ok()
26        .map(|s| s.trim_end().to_string())
27}
28
29/// Read a file containing a single integer.
30pub fn u64(path: impl AsRef<Path>) -> Option<u64> {
31    string(path)?.trim().parse().ok()
32}
33
34/// Read a file containing a single number, as `f64`.
35pub fn f64(path: impl AsRef<Path>) -> Option<f64> {
36    u64(path).map(|v| v as f64)
37}
38
39/// List the subdirectories of `dir` whose names start with `prefix`, paired with
40/// the integer suffix, sorted by that suffix.
41///
42/// Sorting matters: it makes entity row assignment deterministic, and
43/// `read_dir` order is not.
44pub fn numbered_children(dir: impl AsRef<Path>, prefix: &str) -> Vec<(u32, std::path::PathBuf)> {
45    let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
46        return Vec::new();
47    };
48    let mut found: Vec<(u32, std::path::PathBuf)> = entries
49        .flatten()
50        .filter_map(|entry| {
51            let name = entry.file_name();
52            let name = name.to_string_lossy();
53            let index = name.strip_prefix(prefix)?.parse::<u32>().ok()?;
54            Some((index, entry.path()))
55        })
56        .collect();
57    found.sort_by_key(|(index, _)| *index);
58    found
59}
60
61/// Children of `dir` whose names start with `prefix`, sorted by name.
62///
63/// For interfaces like powercap where the suffix is not a bare integer
64/// (`intel-rapl:0:1`).
65pub fn named_children(dir: impl AsRef<Path>, prefix: &str) -> Vec<(String, std::path::PathBuf)> {
66    let Ok(entries) = std::fs::read_dir(dir.as_ref()) else {
67        return Vec::new();
68    };
69    let mut found: Vec<(String, std::path::PathBuf)> = entries
70        .flatten()
71        .filter_map(|entry| {
72            let name = entry.file_name().to_string_lossy().into_owned();
73            if name.starts_with(prefix) {
74                Some((name, entry.path()))
75            } else {
76                None
77            }
78        })
79        .collect();
80    found.sort_by(|a, b| a.0.cmp(&b.0));
81    found
82}
83
84/// Ticks per second, as reported by `sysconf(_SC_CLK_TCK)`.
85///
86/// `/proc/stat` reports CPU time in these units, and the value is not
87/// discoverable from the file itself. It is almost always 100, but reading it
88/// rather than assuming is the difference between publishing nanoseconds and
89/// publishing a plausible wrong number.
90pub fn clock_ticks_per_second() -> u64 {
91    #[cfg(target_os = "linux")]
92    {
93        // SAFETY: sysconf with a valid name; returns -1 on failure.
94        let value = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
95        if value > 0 {
96            return value as u64;
97        }
98    }
99    100
100}
101
102/// Read one file into one cell, counting the outcome.
103///
104/// `required` distinguishes "this file should be here and was not", which is
105/// worth reporting as an error, from "this interface is optional and absent on
106/// this machine", which is simply an unobserved cell. Conflating the two would
107/// make the error count meaningless on any machine missing an optional
108/// interface, which is every machine.
109pub fn sample(
110    out: &mut StateWriter<'_>,
111    outcome: &mut SensorOutcome,
112    row: u32,
113    channel: Option<ChannelId>,
114    path: impl AsRef<Path>,
115    required: bool,
116) {
117    let Some(channel) = channel else { return };
118    let path = path.as_ref();
119    match self::f64(path) {
120        Some(value) => {
121            out.set(row, channel, value);
122            outcome.sample();
123        }
124        None => {
125            // Why, not just "no". A file that exists and will not open is a
126            // different fact from one that was never there.
127            let why = match std::fs::metadata(path) {
128                Ok(_) => Availability::PermissionDenied,
129                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
130                    Availability::PermissionDenied
131                }
132                Err(_) if required => Availability::Unavailable,
133                Err(_) => Availability::Unsupported,
134            };
135            out.missing(row, channel, why);
136            if required {
137                outcome.error();
138            }
139        }
140    }
141}
142
143/// Write an already-parsed value into one cell.
144pub fn emit(
145    out: &mut StateWriter<'_>,
146    outcome: &mut SensorOutcome,
147    row: u32,
148    channel: Option<ChannelId>,
149    value: f64,
150) {
151    let Some(channel) = channel else { return };
152    out.set(row, channel, value);
153    outcome.sample();
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn temp_dir(tag: &str) -> std::path::PathBuf {
161        let dir =
162            std::env::temp_dir().join(format!("corescout-source-{tag}-{}", std::process::id()));
163        let _ = std::fs::remove_dir_all(&dir);
164        std::fs::create_dir_all(&dir).unwrap();
165        dir
166    }
167
168    #[test]
169    fn missing_files_are_absent_not_errors() {
170        assert_eq!(string("/definitely/not/here"), None);
171        assert_eq!(u64("/definitely/not/here"), None);
172        assert_eq!(f64("/definitely/not/here"), None);
173    }
174
175    #[test]
176    fn values_are_trimmed_of_the_kernel_trailing_newline() {
177        let dir = temp_dir("trim");
178        std::fs::write(dir.join("value"), "3600000\n").unwrap();
179        assert_eq!(string(dir.join("value")).as_deref(), Some("3600000"));
180        assert_eq!(u64(dir.join("value")), Some(3_600_000));
181        assert_eq!(f64(dir.join("value")), Some(3_600_000.0));
182    }
183
184    #[test]
185    fn non_numeric_content_is_absent_rather_than_zero() {
186        let dir = temp_dir("garbage");
187        std::fs::write(dir.join("value"), "not a number\n").unwrap();
188        assert_eq!(u64(dir.join("value")), None);
189    }
190
191    #[test]
192    fn numbered_children_are_sorted_numerically() {
193        let dir = temp_dir("numbered");
194        for index in [10u32, 2, 1, 20] {
195            std::fs::create_dir_all(dir.join(format!("state{index}"))).unwrap();
196        }
197        std::fs::create_dir_all(dir.join("other")).unwrap();
198        let found = numbered_children(&dir, "state");
199        let indices: Vec<u32> = found.iter().map(|(i, _)| *i).collect();
200        assert_eq!(
201            indices,
202            vec![1, 2, 10, 20],
203            "lexicographic ordering would put state10 before state2"
204        );
205    }
206
207    #[test]
208    fn named_children_are_sorted_and_filtered() {
209        // The real names are `intel-rapl:0`, `intel-rapl:0:0` and so on. A
210        // colon cannot appear in a filename on Windows, where this test also
211        // runs, so the fixture uses the same shape with a legal separator. The
212        // function under test only cares about the prefix and the ordering.
213        let dir = temp_dir("named");
214        for name in [
215            "intel-rapl-1",
216            "intel-rapl-0",
217            "intel-rapl-0-0",
218            "unrelated",
219        ] {
220            std::fs::create_dir_all(dir.join(name)).unwrap();
221        }
222        let found = named_children(&dir, "intel-rapl-");
223        let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
224        assert_eq!(
225            names,
226            vec!["intel-rapl-0", "intel-rapl-0-0", "intel-rapl-1"]
227        );
228    }
229
230    #[test]
231    fn missing_directories_yield_nothing() {
232        assert!(numbered_children("/definitely/not/here", "x").is_empty());
233        assert!(named_children("/definitely/not/here", "x").is_empty());
234    }
235
236    #[test]
237    fn clock_ticks_are_plausible() {
238        let ticks = clock_ticks_per_second();
239        assert!((1..=10_000).contains(&ticks), "implausible CLK_TCK {ticks}");
240    }
241}