1use std::collections::hash_map::DefaultHasher;
29use std::hash::{Hash, Hasher};
30use std::process::Command;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum ThermalState {
37 Nominal,
38 Throttled { cpu_speed_pct: u32, sched_pct: u32 },
39 Unknown,
40}
41
42#[derive(Debug, Clone)]
43pub struct HwSnapshot {
44 pub os: &'static str,
45 pub arch: &'static str,
46 pub cpu_brand: String,
47 pub total_cpus: usize,
49 pub perf_cores: usize,
51 pub l1d_bytes: usize,
53 pub l2_bytes: usize,
55 pub cache_line: usize,
57 pub thermal: ThermalState,
58}
59
60impl HwSnapshot {
61 pub fn collect() -> Self {
65 let total_cpus = std::thread::available_parallelism()
66 .map(|n| n.get())
67 .unwrap_or(2);
68
69 let mut snap = Self {
70 os: std::env::consts::OS,
71 arch: std::env::consts::ARCH,
72 cpu_brand: String::new(),
73 total_cpus,
74 perf_cores: 0,
75 l1d_bytes: 0,
76 l2_bytes: 0,
77 cache_line: 0,
78 thermal: ThermalState::Unknown,
79 };
80
81 #[cfg(target_os = "macos")]
82 {
83 snap.cpu_brand = sysctl_str("machdep.cpu.brand_string").unwrap_or_default();
84 snap.perf_cores = sysctl_usize("hw.perflevel0.physicalcpu").unwrap_or(0);
85 snap.l1d_bytes = sysctl_usize("hw.l1dcachesize").unwrap_or(0);
86 snap.l2_bytes = sysctl_usize("hw.l2cachesize").unwrap_or(0);
87 snap.cache_line = sysctl_usize("hw.cachelinesize").unwrap_or(0);
88 snap.thermal = read_pmset_thermal().unwrap_or(ThermalState::Unknown);
89 }
90
91 snap
92 }
93
94 pub fn fingerprint(&self) -> u64 {
98 let mut h = DefaultHasher::new();
99 self.os.hash(&mut h);
100 self.arch.hash(&mut h);
101 self.cpu_brand.hash(&mut h);
102 self.total_cpus.hash(&mut h);
103 self.perf_cores.hash(&mut h);
104 self.l1d_bytes.hash(&mut h);
105 self.l2_bytes.hash(&mut h);
106 self.cache_line.hash(&mut h);
107 h.finish()
108 }
109
110 pub fn is_throttled(&self) -> bool {
112 matches!(self.thermal, ThermalState::Throttled { .. })
113 }
114}
115
116#[cfg(target_os = "macos")]
117fn sysctl_usize(name: &str) -> Option<usize> {
118 use std::ffi::CString;
119 let cname = CString::new(name).ok()?;
120 let mut val: u64 = 0;
121 let mut len = std::mem::size_of::<u64>();
122 unsafe extern "C" {
123 fn sysctlbyname(
124 name: *const std::os::raw::c_char,
125 oldp: *mut std::os::raw::c_void,
126 oldlenp: *mut usize,
127 newp: *mut std::os::raw::c_void,
128 newlen: usize,
129 ) -> std::os::raw::c_int;
130 }
131 let rc = unsafe {
132 sysctlbyname(
133 cname.as_ptr(),
134 &mut val as *mut u64 as *mut _,
135 &mut len,
136 std::ptr::null_mut(),
137 0,
138 )
139 };
140 if rc == 0 { Some(val as usize) } else { None }
141}
142
143#[cfg(target_os = "macos")]
144fn sysctl_str(name: &str) -> Option<String> {
145 use std::ffi::CString;
146 let cname = CString::new(name).ok()?;
147 unsafe extern "C" {
148 fn sysctlbyname(
149 name: *const std::os::raw::c_char,
150 oldp: *mut std::os::raw::c_void,
151 oldlenp: *mut usize,
152 newp: *mut std::os::raw::c_void,
153 newlen: usize,
154 ) -> std::os::raw::c_int;
155 }
156 let mut len: usize = 0;
158 let rc = unsafe {
159 sysctlbyname(
160 cname.as_ptr(),
161 std::ptr::null_mut(),
162 &mut len,
163 std::ptr::null_mut(),
164 0,
165 )
166 };
167 if rc != 0 || len == 0 {
168 return None;
169 }
170 let mut buf = vec![0u8; len];
171 let rc = unsafe {
172 sysctlbyname(
173 cname.as_ptr(),
174 buf.as_mut_ptr() as *mut _,
175 &mut len,
176 std::ptr::null_mut(),
177 0,
178 )
179 };
180 if rc != 0 {
181 return None;
182 }
183 if let Some(&0) = buf.last() {
185 buf.pop();
186 }
187 String::from_utf8(buf).ok()
188}
189
190#[cfg(target_os = "macos")]
191fn read_pmset_thermal() -> Option<ThermalState> {
192 let out = Command::new("pmset").args(["-g", "therm"]).output().ok()?;
193 if !out.status.success() {
194 return None;
195 }
196 let s = String::from_utf8_lossy(&out.stdout);
197 let mut cpu_speed = 100u32;
198 let mut sched = 100u32;
199 for line in s.lines() {
200 if let Some(rest) = line.split('=').nth(1) {
201 let val = rest.trim().parse::<u32>().ok();
202 if line.contains("CPU_Speed_Limit") {
203 if let Some(v) = val {
204 cpu_speed = v;
205 }
206 } else if line.contains("CPU_Scheduler_Limit")
207 && let Some(v) = val
208 {
209 sched = v;
210 }
211 }
212 }
213 Some(if cpu_speed < 100 || sched < 100 {
214 ThermalState::Throttled {
215 cpu_speed_pct: cpu_speed,
216 sched_pct: sched,
217 }
218 } else {
219 ThermalState::Nominal
220 })
221}
222
223#[cfg(not(target_os = "macos"))]
224#[allow(dead_code)]
225fn read_pmset_thermal() -> Option<ThermalState> {
226 None
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn snapshot_doesnt_panic() {
235 let snap = HwSnapshot::collect();
236 assert!(!snap.os.is_empty());
238 assert!(!snap.arch.is_empty());
239 }
240
241 #[test]
242 fn fingerprint_is_stable_across_collects() {
243 let a = HwSnapshot::collect();
246 let b = HwSnapshot::collect();
247 assert_eq!(a.fingerprint(), b.fingerprint());
248 }
249}