1use std::array::TryFromSliceError;
7use std::collections::HashMap;
8use std::io::ErrorKind;
9use std::num::TryFromIntError;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use anyhow::Result;
14use tokio::sync::RwLock;
15use tracing::debug;
16use zbus::zvariant::ObjectPath;
17
18use crate::config::Config;
19use crate::dbus::zbus_systemd::ManagerProxy;
20use crate::dbus::zbus_unit::UnitProxy;
21use crate::MachineStats;
22
23pub type BootBlameStats = HashMap<String, f64>;
25
26const BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id";
27const BOOT_BLAME_CACHE_SUFFIX: &str = "boot_blame.bin";
28
29type BootCacheResult<T> = std::result::Result<T, BootCacheError>;
30
31#[derive(Debug, thiserror::Error)]
32enum BootCacheError {
33 #[error("boot cache I/O error: {0}")]
34 Io(#[from] std::io::Error),
35 #[error("boot id from {BOOT_ID_PATH} was empty")]
36 EmptyBootId,
37 #[error("boot cache payload decode error: {0}")]
38 InvalidPayload(&'static str),
39 #[error("boot cache UTF-8 decode error: {0}")]
40 Utf8(#[from] std::string::FromUtf8Error),
41 #[error("boot cache integer conversion error: {0}")]
42 IntConversion(#[from] TryFromIntError),
43 #[error("boot cache slice conversion error: {0}")]
44 SliceConversion(#[from] TryFromSliceError),
45}
46
47fn cache_file_path(cache_dir: &Path, boot_id: &str) -> PathBuf {
48 cache_dir.join(format!("{boot_id}.{BOOT_BLAME_CACHE_SUFFIX}"))
49}
50
51async fn get_boot_id() -> BootCacheResult<String> {
52 let boot_id = tokio::fs::read_to_string(BOOT_ID_PATH).await?;
53 let boot_id = boot_id.trim().to_string();
54 if boot_id.is_empty() {
55 return Err(BootCacheError::EmptyBootId);
56 }
57 Ok(boot_id)
58}
59
60fn encode_boot_blame_stats(stats: &BootBlameStats) -> BootCacheResult<Vec<u8>> {
61 let mut out = Vec::new();
62 let entry_count = u32::try_from(stats.len())?;
63 out.extend_from_slice(&entry_count.to_le_bytes());
64
65 for (unit_name, activation_time) in stats {
66 let unit_name_bytes = unit_name.as_bytes();
67 let unit_name_len = u32::try_from(unit_name_bytes.len())?;
68 out.extend_from_slice(&unit_name_len.to_le_bytes());
69 out.extend_from_slice(unit_name_bytes);
70 out.extend_from_slice(&activation_time.to_le_bytes());
71 }
72
73 Ok(out)
74}
75
76fn decode_boot_blame_stats(content: &[u8]) -> BootCacheResult<BootBlameStats> {
77 const U32_BYTES: usize = std::mem::size_of::<u32>();
78 const F64_BYTES: usize = std::mem::size_of::<f64>();
79 fn read_u32(bytes: &[u8], offset: &mut usize) -> BootCacheResult<u32> {
80 if *offset + std::mem::size_of::<u32>() > bytes.len() {
81 return Err(BootCacheError::InvalidPayload("unexpected end of payload"));
82 }
83 let value =
84 u32::from_le_bytes(bytes[*offset..*offset + std::mem::size_of::<u32>()].try_into()?);
85 *offset += std::mem::size_of::<u32>();
86 Ok(value)
87 }
88
89 if content.len() < U32_BYTES {
90 return Err(BootCacheError::InvalidPayload("payload too small"));
91 }
92
93 let mut offset = 0usize;
94 let entry_count = read_u32(content, &mut offset)? as usize;
95 let mut stats = BootBlameStats::with_capacity(entry_count);
96
97 for _ in 0..entry_count {
98 let name_len = read_u32(content, &mut offset)? as usize;
99 if offset + name_len + F64_BYTES > content.len() {
100 return Err(BootCacheError::InvalidPayload("invalid payload size"));
101 }
102 let unit_name = String::from_utf8(content[offset..offset + name_len].to_vec())?;
103 offset += name_len;
104 let activation_time = f64::from_le_bytes(content[offset..offset + F64_BYTES].try_into()?);
105 offset += F64_BYTES;
106 stats.insert(unit_name, activation_time);
107 }
108
109 if offset != content.len() {
110 return Err(BootCacheError::InvalidPayload("trailing bytes in payload"));
111 }
112
113 Ok(stats)
114}
115
116async fn read_cached_boot_blame_from_dir(
117 cache_dir: &Path,
118 boot_id: &str,
119) -> BootCacheResult<Option<BootBlameStats>> {
120 let cache_path = cache_file_path(cache_dir, boot_id);
121 let content = match tokio::fs::read(&cache_path).await {
122 Ok(content) => content,
123 Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
124 Err(err) => return Err(err.into()),
125 };
126 Ok(Some(decode_boot_blame_stats(&content)?))
127}
128
129async fn write_cached_boot_blame_to_dir(
130 cache_dir: &Path,
131 boot_id: &str,
132 stats: &BootBlameStats,
133) -> BootCacheResult<()> {
134 tokio::fs::create_dir_all(cache_dir).await?;
135 let cache_path = cache_file_path(cache_dir, boot_id);
136 let encoded = encode_boot_blame_stats(stats)?;
137 tokio::fs::write(cache_path, encoded).await?;
138 Ok(())
139}
140
141async fn get_unit_activation_time(
144 connection: &zbus::Connection,
145 unit_path: &ObjectPath<'_>,
146) -> Result<f64> {
147 let unit_proxy = UnitProxy::builder(connection)
148 .cache_properties(zbus::proxy::CacheProperties::No)
149 .path(unit_path)?
150 .build()
151 .await?;
152
153 let inactive_exit = unit_proxy.inactive_exit_timestamp().await?;
154 let active_enter = unit_proxy.active_enter_timestamp().await?;
155
156 if inactive_exit == 0 || active_enter == 0 {
158 return Ok(0.0);
159 }
160
161 let activation_time_usec = active_enter.saturating_sub(inactive_exit);
163 let activation_time_sec = activation_time_usec as f64 / 1_000_000.0;
164
165 Ok(activation_time_sec)
166}
167
168pub async fn update_boot_blame_stats(
170 config: Arc<Config>,
171 connection: zbus::Connection,
172 machine_stats: Arc<RwLock<MachineStats>>,
173) -> Result<()> {
174 debug!("Starting boot blame stats collection");
175
176 let mut maybe_boot_id = None;
177 if config.boot_blame.cache_enabled {
178 let cached_stats = machine_stats.read().await.boot_blame.clone();
179 if cached_stats.is_some() {
180 debug!("Using in-memory cached boot blame stats");
181 return Ok(());
182 }
183
184 let cache_dir = Path::new(&config.boot_blame.cache_dir);
185 match get_boot_id().await {
186 Ok(boot_id) => {
187 match read_cached_boot_blame_from_dir(cache_dir, &boot_id).await {
188 Ok(Some(cached_boot_blame)) => {
189 let cache_path = cache_file_path(cache_dir, &boot_id);
190 debug!(
191 "Using cached boot blame stats from {}",
192 cache_path.display()
193 );
194 machine_stats.write().await.boot_blame = Some(cached_boot_blame);
195 return Ok(());
196 }
197 Ok(None) => {
198 debug!("No cached boot blame stats found for boot id {}", boot_id);
199 }
200 Err(err) => {
201 debug!(
202 "Failed to load boot blame cache for boot id {}: {}",
203 boot_id, err
204 );
205 }
206 }
207 maybe_boot_id = Some(boot_id);
208 }
209 Err(err) => {
210 debug!("Failed to retrieve boot id for boot blame cache: {}", err);
211 }
212 }
213 }
214
215 let systemd_proxy = ManagerProxy::builder(&connection)
216 .cache_properties(zbus::proxy::CacheProperties::No)
217 .build()
218 .await?;
219 let units = systemd_proxy.list_units().await?;
220
221 let mut unit_times: Vec<(String, f64)> = Vec::new();
222
223 for unit_info in units {
225 let unit_name = unit_info.0;
226 let unit_path = unit_info.6;
227
228 if config.boot_blame.blocklist.contains(&unit_name) {
230 debug!("Skipping boot blame for {} due to blocklist", &unit_name);
231 continue;
232 }
233 if !config.boot_blame.allowlist.is_empty()
235 && !config.boot_blame.allowlist.contains(&unit_name)
236 {
237 continue;
238 }
239
240 match get_unit_activation_time(&connection, &unit_path).await {
241 Ok(time) if time > 0.0 => {
242 unit_times.push((unit_name, time));
243 }
244 Ok(_) => {
245 }
247 Err(e) => {
248 debug!("Failed to get activation time for {}: {}", unit_name, e);
249 }
250 }
251 }
252
253 unit_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
255
256 let num_slowest = config.boot_blame.num_slowest_units as usize;
258 unit_times.truncate(num_slowest);
259
260 let boot_blame_stats: BootBlameStats = unit_times.into_iter().collect();
262
263 debug!("Collected {} boot blame stats", boot_blame_stats.len());
264
265 let mut stats = machine_stats.write().await;
267 stats.boot_blame = Some(boot_blame_stats);
268 if config.boot_blame.cache_enabled {
269 if let Some(boot_id) = maybe_boot_id {
270 if let Some(cached_stats) = stats.boot_blame.as_ref() {
271 let cache_dir = Path::new(&config.boot_blame.cache_dir);
272 if let Err(err) =
273 write_cached_boot_blame_to_dir(cache_dir, &boot_id, cached_stats).await
274 {
275 debug!(
276 "Failed to write boot blame cache for boot id {} to {}: {}",
277 boot_id, config.boot_blame.cache_dir, err
278 );
279 } else {
280 debug!("Updated boot blame cache for boot id {}", boot_id);
281 }
282 }
283 }
284 }
285
286 Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn test_boot_blame_cache_encode_decode_roundtrip() {
295 let mut stats = BootBlameStats::new();
296 stats.insert("foo.service".to_string(), 12.3);
297 stats.insert("bar.service".to_string(), 45.6);
298
299 let encoded = encode_boot_blame_stats(&stats).expect("encode should succeed");
300 let decoded = decode_boot_blame_stats(&encoded).expect("decode should succeed");
301 assert_eq!(stats, decoded);
302 }
303
304 #[test]
305 fn test_boot_blame_cache_decode_invalid_payload() {
306 let invalid_payload = vec![0, 1, 2];
307 assert!(decode_boot_blame_stats(&invalid_payload).is_err());
308 }
309
310 #[tokio::test]
311 async fn test_boot_blame_cache_read_write_roundtrip() {
312 let temp_dir = tempfile::tempdir().expect("create temp dir");
313 let boot_id = "00000000-0000-0000-0000-000000000001";
314 let mut stats = BootBlameStats::new();
315 stats.insert("foo.service".to_string(), 1.25);
316
317 write_cached_boot_blame_to_dir(temp_dir.path(), boot_id, &stats)
318 .await
319 .expect("write cache");
320 let read_stats = read_cached_boot_blame_from_dir(temp_dir.path(), boot_id)
321 .await
322 .expect("read cache");
323 assert_eq!(Some(stats), read_stats);
324 }
325
326 #[tokio::test]
327 async fn test_boot_blame_cache_read_missing_file() {
328 let temp_dir = tempfile::tempdir().expect("create temp dir");
329 let missing = read_cached_boot_blame_from_dir(
330 temp_dir.path(),
331 "00000000-0000-0000-0000-000000000002",
332 )
333 .await
334 .expect("missing cache should not error");
335 assert!(missing.is_none());
336 }
337}