Skip to main content

monitord/
units.rs

1//! # units module
2//!
3//! All main systemd unit statistics. Counts of types of units, unit states and
4//! queued jobs. We also house service specific statistics and system unit states.
5
6use std::collections::HashMap;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::Instant;
10use std::time::SystemTime;
11use std::time::UNIX_EPOCH;
12
13use struct_field_names_as_array::FieldNamesAsArray;
14use thiserror::Error;
15use tokio::sync::RwLock;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18use tracing::debug;
19use tracing::error;
20use tracing::warn;
21use tracing::Instrument;
22use zbus::zvariant::ObjectPath;
23use zbus::zvariant::OwnedObjectPath;
24
25#[derive(Error, Debug)]
26pub enum MonitordUnitsError {
27    #[error("Units D-Bus error: {0}")]
28    ZbusError(#[from] zbus::Error),
29    #[error("Integer conversion error: {0}")]
30    IntConversion(#[from] std::num::TryFromIntError),
31    #[error("System time error: {0}")]
32    SystemTimeError(#[from] std::time::SystemTimeError),
33}
34
35use crate::timer::TimerStats;
36use crate::MachineStats;
37
38// Re-export the enums and function from unit_constants for backwards compatibility
39pub use crate::unit_constants::is_unit_unhealthy;
40pub use crate::unit_constants::is_unit_unhealthy_for_service;
41pub use crate::unit_constants::SystemdUnitActiveState;
42pub use crate::unit_constants::SystemdUnitLoadState;
43pub use crate::unit_constants::SYSTEMD_SERVICE_SUFFIX;
44pub use crate::unit_constants::SYSTEMD_TIMER_SUFFIX;
45
46/// Inner timing breakdown for the units collector D-Bus phases.
47///
48/// Helps locate which step of unit collection dominates wall time when the
49/// `units` collector is the slowest one in `MonitordStats::collector_timings`.
50#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
51pub struct UnitsCollectionTimings {
52    /// Time for the systemd ListUnits D-Bus call (one batched call returning all units).
53    pub list_units_ms: f64,
54    /// Time for filesystem unit file stats collection (runs concurrently with list_units).
55    pub unit_files_ms: f64,
56    /// Time spent in the per-unit parse loop, including any per-unit D-Bus calls
57    /// (timer property fetches, state stats, service stats).
58    pub per_unit_loop_ms: f64,
59    /// Number of timer units whose properties were fetched via D-Bus this run.
60    pub timer_dbus_fetches: u64,
61    /// Number of unit state D-Bus fetches this run (when state_stats_time_in_state is enabled).
62    pub state_dbus_fetches: u64,
63    /// Number of per-service D-Bus property fetches this run.
64    pub service_dbus_fetches: u64,
65    /// Slowest units (by per-unit collection duration, descending) this run,
66    /// truncated to `units.slowest_units_count`. Empty when disabled (count 0).
67    pub slowest_units: Vec<(String, f64)>,
68}
69
70/// Unit file counts for a scope (root or user), broken down by unit type.
71#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
72pub struct UnitFilesScope {
73    /// Generated unit files by type (e.g. "service" => 2, "mount" => 5)
74    pub generated: HashMap<String, u64>,
75    /// Transient unit files by type (e.g. "service" => 10, "scope" => 6)
76    pub transient: HashMap<String, u64>,
77}
78
79/// Unit file statistics collected from the filesystem for root and user scopes.
80#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
81pub struct UnitFilesStats {
82    pub root: UnitFilesScope,
83    pub user: UnitFilesScope,
84}
85
86#[derive(
87    serde::Serialize, serde::Deserialize, Clone, Debug, Default, FieldNamesAsArray, PartialEq,
88)]
89
90/// Aggregated systemd unit statistics: counts by type, load state, active state,
91/// plus optional per-service and per-timer detailed metrics
92pub struct SystemdUnitStats {
93    /// Number of units in the "activating" state (in the process of being started)
94    pub activating_units: u64,
95    /// Number of units in the "active" state (currently started and running)
96    pub active_units: u64,
97    /// Number of automount units (on-demand filesystem mount points)
98    pub automount_units: u64,
99    /// Number of device units (kernel devices exposed to systemd by udev)
100    pub device_units: u64,
101    /// Number of units in the "failed" state (exited with error, crashed, or timed out)
102    pub failed_units: u64,
103    /// Number of units in the "inactive" state (not currently running)
104    pub inactive_units: u64,
105    /// Number of pending jobs queued in the systemd job scheduler
106    pub jobs_queued: u64,
107    /// Number of units whose unit file has been successfully loaded into memory
108    pub loaded_units: u64,
109    /// Number of units whose unit file is masked (symlinked to /dev/null, cannot be started)
110    pub masked_units: u64,
111    /// Number of mount units (filesystem mount points managed by systemd)
112    pub mount_units: u64,
113    /// Number of units whose unit file could not be found on disk
114    pub not_found_units: u64,
115    /// Number of path units (file/directory watch triggers)
116    pub path_units: u64,
117    /// Number of scope units (externally created process groups, e.g. user sessions)
118    pub scope_units: u64,
119    /// Number of service units (daemon/process lifecycle management)
120    pub service_units: u64,
121    /// Number of slice units (resource management groups in the cgroup hierarchy)
122    pub slice_units: u64,
123    /// Number of socket units (IPC/network socket activation endpoints)
124    pub socket_units: u64,
125    /// Number of target units (synchronization points for grouping units)
126    pub target_units: u64,
127    /// Number of timer units (calendar/monotonic scheduled triggers)
128    pub timer_units: u64,
129    /// Number of timer units with Persistent=yes (triggers missed runs after downtime)
130    pub timer_persistent_units: u64,
131    /// Number of timer units with RemainAfterElapse=yes (stays loaded after firing)
132    pub timer_remain_after_elapse: u64,
133    /// Total number of units known to systemd (all types, all states)
134    pub total_units: u64,
135    /// Unit file statistics from the filesystem (e.g. generator output counts)
136    pub unit_files: UnitFilesStats,
137    /// Per-service detailed metrics keyed by unit name (e.g. "sshd.service")
138    pub service_stats: HashMap<String, ServiceStats>,
139    /// Per-timer detailed metrics keyed by unit name (e.g. "logrotate.timer")
140    pub timer_stats: HashMap<String, TimerStats>,
141    /// Per-unit active/load state tracking keyed by unit name
142    pub unit_states: HashMap<String, UnitStates>,
143    /// Inner timing breakdown for this collector. Zero-valued before the first
144    /// run completes. On the varlink path, `list_units_ms`/`per_unit_loop_ms`
145    /// cover the varlink fetch, local parse, and the two D-Bus phases that have
146    /// no varlink equivalent yet — the oneshot type lookups and the timer
147    /// backfill — so `service_dbus_fetches` and `timer_dbus_fetches` may both be
148    /// nonzero there. Only `state_dbus_fetches` stays zero on that path.
149    pub collection_timings: UnitsCollectionTimings,
150}
151
152/// Per-service metrics from the org.freedesktop.systemd1.Service and Unit D-Bus interfaces.
153/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
154#[derive(
155    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
156)]
157pub struct ServiceStats {
158    /// Realtime timestamp (usec since epoch) when the unit most recently entered the active state
159    pub active_enter_timestamp: u64,
160    /// Realtime timestamp (usec since epoch) when the unit most recently left the active state
161    pub active_exit_timestamp: u64,
162    /// Total CPU time consumed by this service's cgroup in nanoseconds
163    pub cpuusage_nsec: u64,
164    /// Realtime timestamp (usec since epoch) when the unit most recently left the inactive state
165    pub inactive_exit_timestamp: u64,
166    /// Total bytes read from block I/O by this service's cgroup
167    pub ioread_bytes: u64,
168    /// Total number of block I/O read operations by this service's cgroup
169    pub ioread_operations: u64,
170    /// Memory available to the service (MemoryAvailable from cgroup), in bytes
171    pub memory_available: u64,
172    /// Current memory usage of the service's cgroup in bytes
173    pub memory_current: u64,
174    /// Number of times systemd has restarted this service (automatic restarts)
175    pub nrestarts: u32,
176    /// Current number of processes in this service's cgroup
177    pub processes: u32,
178    /// Configured restart delay for this service in microseconds (RestartUSec)
179    pub restart_usec: u64,
180    /// Realtime timestamp (usec since epoch) of the most recent state change of any kind
181    pub state_change_timestamp: u64,
182    /// errno-style exit status code from the main process (0 = success)
183    pub status_errno: i32,
184    /// Current number of tasks (threads) in this service's cgroup
185    pub tasks_current: u64,
186    /// Timeout in microseconds for the cleanup of resources after the service exits
187    pub timeout_clean_usec: u64,
188    /// Watchdog timeout in microseconds; the service must ping within this interval or be killed
189    pub watchdog_usec: u64,
190}
191
192/// Per-unit state tracking combining active state, load state, and computed health.
193/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
194#[derive(
195    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
196)]
197pub struct UnitStates {
198    /// Current active state of the unit (active, inactive, failed, activating, deactivating, reloading)
199    pub active_state: SystemdUnitActiveState,
200    /// Current load state of the unit (loaded, error, masked, not_found)
201    pub load_state: SystemdUnitLoadState,
202    /// Computed health flag: true when a loaded unit is not active, or when load state is error/not_found.
203    /// Masked units are never marked unhealthy since masking is an intentional admin action.
204    /// Optional config can ignore inactive oneshot services.
205    pub unhealthy: bool,
206    /// Microseconds elapsed since the unit's most recent state change.
207    /// None when time-in-state tracking is disabled in config (expensive D-Bus
208    /// lookup per unit), or when no state change timestamp is known.
209    pub time_in_state_usecs: Option<u64>,
210}
211
212// Declare state types
213// Reference: https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html
214// SubState can be unit-type-specific so can't enum
215
216#[derive(Debug)]
217pub struct ListedUnit {
218    pub name: String,                      // The primary unit name
219    pub description: String,               // The human readable description
220    pub load_state: String, // The load state (i.e. whether the unit file has been loaded successfully)
221    pub active_state: String, // The active state (i.e. whether the unit is currently started or not)
222    pub sub_state: String,    // The sub state (i.e. unit type more specific state)
223    pub follow_unit: String, // A unit that is being followed in its state by this unit, if there is any, otherwise the empty string
224    pub unit_object_path: OwnedObjectPath, // The unit object path
225    pub job_id: u32, // If there is a job queued for the job unit, the numeric job id, 0 otherwise
226    pub job_type: String, // The job type as string
227    pub job_object_path: OwnedObjectPath, // The job object path
228}
229impl
230    From<(
231        String,
232        String,
233        String,
234        String,
235        String,
236        String,
237        OwnedObjectPath,
238        u32,
239        String,
240        OwnedObjectPath,
241    )> for ListedUnit
242{
243    fn from(
244        tuple: (
245            String,
246            String,
247            String,
248            String,
249            String,
250            String,
251            OwnedObjectPath,
252            u32,
253            String,
254            OwnedObjectPath,
255        ),
256    ) -> Self {
257        ListedUnit {
258            name: tuple.0,
259            description: tuple.1,
260            load_state: tuple.2,
261            active_state: tuple.3,
262            sub_state: tuple.4,
263            follow_unit: tuple.5,
264            unit_object_path: tuple.6,
265            job_id: tuple.7,
266            job_type: tuple.8,
267            job_object_path: tuple.9,
268        }
269    }
270}
271
272pub const SERVICE_FIELD_NAMES: &[&str] = &ServiceStats::FIELD_NAMES_AS_ARRAY;
273pub const UNIT_FIELD_NAMES: &[&str] = &SystemdUnitStats::FIELD_NAMES_AS_ARRAY;
274pub const UNIT_STATES_FIELD_NAMES: &[&str] = &UnitStates::FIELD_NAMES_AS_ARRAY;
275
276/// Pull out selected systemd service statistics
277///
278/// The seven cgroup-derived fields (`cpuusage_nsec`, `ioread_bytes`,
279/// `ioread_operations`, `memory_current`, `memory_available`, `processes`,
280/// `tasks_current`) come from cgroupfs via `crate::cgroup` (#221), read in
281/// parallel with the remaining D-Bus properties. `fs_root` prefixes the
282/// cgroup mount — empty for the host, `/proc/<leader>/root` for containers.
283/// `host_memory` is read once per collection cycle in `parse_unit_state` and
284/// shared by every unit. A cgroup field that cgroupfs has no data for falls
285/// back to its D-Bus property, which is also what covers cgroup v1 hosts
286/// (no v2 files → all fallback, no new failure mode).
287#[tracing::instrument(level = "debug", skip(connection, object_path))]
288async fn parse_service(
289    connection: &zbus::Connection,
290    name: &str,
291    object_path: &OwnedObjectPath,
292    fs_root: &str,
293    host_memory: Option<crate::cgroup::HostMemory>,
294) -> Result<ServiceStats, MonitordUnitsError> {
295    debug!("Parsing service {} stats", name);
296
297    let sp = crate::dbus::zbus_service::ServiceProxy::builder(connection)
298        .cache_properties(zbus::proxy::CacheProperties::No)
299        .path(object_path.clone())?
300        .build()
301        .await?;
302    let up = crate::dbus::zbus_unit::UnitProxy::builder(connection)
303        .cache_properties(zbus::proxy::CacheProperties::No)
304        .path(object_path.clone())?
305        .build()
306        .await?;
307
308    // The cgroup path gates the filesystem read; the main and control PIDs
309    // are folded into the process count the way `GetProcesses` does (both
310    // can sit outside the cgroup).
311    // Use tokio::join! without tokio::spawn to avoid per-task allocation overhead.
312    // These all share the same D-Bus connection so spawn adds no parallelism benefit.
313    let (control_group, main_pid, control_pid) =
314        tokio::join!(sp.control_group(), sp.main_pid(), sp.control_pid());
315    let (control_group, main_pid, control_pid) = (control_group?, main_pid?, control_pid?);
316    let mut extra_pids = Vec::with_capacity(2);
317    for pid in [main_pid, control_pid] {
318        if pid != 0 {
319            extra_pids.push(pid);
320        }
321    }
322
323    // The filesystem read joins the remaining D-Bus properties in one batch,
324    // so cgroupfs IO never serializes behind IPC. An empty cgroup path
325    // (inactive unit) short-circuits inside `read_service_cgroup` and every
326    // field below falls back, exactly like an unreadable cgroup.
327    let cgroup_stats =
328        crate::cgroup::read_service_cgroup(fs_root, &control_group, &extra_pids, host_memory);
329
330    let (
331        active_enter_timestamp,
332        active_exit_timestamp,
333        inactive_exit_timestamp,
334        nrestarts,
335        restart_usec,
336        state_change_timestamp,
337        status_errno,
338        timeout_clean_usec,
339        watchdog_usec,
340        cgroup_stats,
341    ) = tokio::join!(
342        up.active_enter_timestamp(),
343        up.active_exit_timestamp(),
344        up.inactive_exit_timestamp(),
345        sp.nrestarts(),
346        sp.restart_usec(),
347        up.state_change_timestamp(),
348        sp.status_errno(),
349        sp.timeout_clean_usec(),
350        sp.watchdog_usec(),
351        cgroup_stats,
352    );
353
354    // Per-field D-Bus fallback for whatever cgroupfs had no data for: only
355    // missing fields pay for a fallback call, in the same single batch — so
356    // the hot path (cgroup present, the common case for running services)
357    // issues none, and a property that errors only fails its own field via
358    // the `?` below rather than the whole service (matching how a missing
359    // cgroup file behaves).
360    let (
361        cpuusage_nsec,
362        ioread_bytes,
363        ioread_operations,
364        memory_current,
365        memory_available,
366        tasks_current,
367    ) = tokio::join!(
368        async {
369            match cgroup_stats.cpu_usage_nsec {
370                Some(value) => Ok(value),
371                None => sp.cpuusage_nsec().await,
372            }
373        },
374        async {
375            match cgroup_stats.io_read_bytes {
376                Some(value) => Ok(value),
377                None => sp.ioread_bytes().await,
378            }
379        },
380        async {
381            match cgroup_stats.io_read_operations {
382                Some(value) => Ok(value),
383                None => sp.ioread_operations().await,
384            }
385        },
386        async {
387            match cgroup_stats.memory_current {
388                Some(value) => Ok(value),
389                None => sp.memory_current().await,
390            }
391        },
392        async {
393            match cgroup_stats.memory_available {
394                Some(value) => Ok(value),
395                None => sp.memory_available().await,
396            }
397        },
398        async {
399            match cgroup_stats.tasks_current {
400                Some(value) => Ok(value),
401                None => sp.tasks_current().await,
402            }
403        },
404    );
405    let (
406        cpuusage_nsec,
407        ioread_bytes,
408        ioread_operations,
409        memory_current,
410        memory_available,
411        tasks_current,
412    ) = (
413        cpuusage_nsec?,
414        ioread_bytes?,
415        ioread_operations?,
416        memory_current?,
417        memory_available?,
418        tasks_current?,
419    );
420
421    Ok(ServiceStats {
422        active_enter_timestamp: active_enter_timestamp?,
423        active_exit_timestamp: active_exit_timestamp?,
424        cpuusage_nsec,
425        inactive_exit_timestamp: inactive_exit_timestamp?,
426        ioread_bytes,
427        ioread_operations,
428        memory_current,
429        memory_available,
430        nrestarts: nrestarts?,
431        processes: cgroup_stats.processes,
432        restart_usec: restart_usec?,
433        state_change_timestamp: state_change_timestamp?,
434        status_errno: status_errno?,
435        tasks_current,
436        timeout_clean_usec: timeout_clean_usec?,
437        watchdog_usec: watchdog_usec?,
438    })
439}
440
441/// Compute microseconds in the current state from a state-change timestamp.
442///
443/// A zero timestamp means no state change was ever recorded (or the lookup
444/// failed and was zeroed by the caller): time in state is unknown, so this
445/// returns `None` rather than a bogus huge elapsed time. Shared by the D-Bus
446/// path (`get_time_in_state`) and the varlink path so both agree.
447pub(crate) fn compute_time_in_state(now_usec: u64, state_change_timestamp: u64) -> Option<u64> {
448    if state_change_timestamp == 0 {
449        return None;
450    }
451    Some(now_usec.saturating_sub(state_change_timestamp))
452}
453
454#[tracing::instrument(level = "debug", skip(connection))]
455async fn get_time_in_state(
456    connection: Option<&zbus::Connection>,
457    unit: &ListedUnit,
458) -> Result<Option<u64>, MonitordUnitsError> {
459    match connection {
460        Some(c) => {
461            let up = crate::dbus::zbus_unit::UnitProxy::builder(c)
462                .cache_properties(zbus::proxy::CacheProperties::No)
463                .path(ObjectPath::from(unit.unit_object_path.clone()))?
464                .build()
465                .await?;
466            let now: u64 = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() * 1_000_000;
467            let state_change_timestamp = match up.state_change_timestamp().await {
468                Ok(sct) => sct,
469                Err(err) => {
470                    error!(
471                        "Unable to get state_change_timestamp for {} - Setting to 0: {:?}",
472                        &unit.name, err,
473                    );
474                    0
475                }
476            };
477            Ok(compute_time_in_state(now, state_change_timestamp))
478        }
479        None => {
480            error!("No zbus connection passed, but time_in_state_usecs enabled");
481            Ok(None)
482        }
483    }
484}
485
486/// Parse state of a unit into a `UnitStates` entry for the caller to merge.
487///
488/// Returns `(did_dbus_fetch, entry)`: `did_dbus_fetch` is true when an actual
489/// time-in-state D-Bus fetch was performed, so callers can keep
490/// `state_dbus_fetches` honest. Allowlist/blocklist short-circuits return
491/// `(false, None)` — the unit is simply not tracked in `unit_states`.
492#[tracing::instrument(level = "debug", skip(config, connection))]
493pub async fn parse_state(
494    unit: &ListedUnit,
495    config: &crate::config::UnitsConfig,
496    connection: Option<&zbus::Connection>,
497) -> Result<(bool, Option<UnitStates>), MonitordUnitsError> {
498    if config.state_stats_blocklist.contains(&unit.name) {
499        debug!("Skipping state stats for {} due to blocklist", &unit.name);
500        return Ok((false, None));
501    }
502    if !config.state_stats_allowlist.is_empty()
503        && !config.state_stats_allowlist.contains(&unit.name)
504    {
505        return Ok((false, None));
506    }
507    let active_state = SystemdUnitActiveState::from_str(&unit.active_state)
508        .unwrap_or(SystemdUnitActiveState::unknown);
509    let load_state = SystemdUnitLoadState::from_str(&unit.load_state.replace('-', "_"))
510        .unwrap_or(SystemdUnitLoadState::unknown);
511    let mut is_oneshot_service = false;
512    if config.ignore_inactive_oneshot_services
513        && unit.name.ends_with(SYSTEMD_SERVICE_SUFFIX)
514        && matches!(active_state, SystemdUnitActiveState::inactive)
515        && matches!(load_state, SystemdUnitLoadState::loaded)
516    {
517        if let Some(conn) = connection {
518            match is_oneshot_service_unit(conn, unit).await {
519                Ok(is_oneshot) => is_oneshot_service = is_oneshot,
520                Err(err) => warn!(
521                    "Unable to get Service.Type for {} (assuming not oneshot): {:?}",
522                    &unit.name, err
523                ),
524            }
525        }
526    }
527
528    // Get the state_change_timestamp to determine time in usecs we've been in current state
529    let mut time_in_state_usecs: Option<u64> = None;
530    let mut did_dbus_fetch = false;
531    if config.state_stats_time_in_state {
532        time_in_state_usecs = get_time_in_state(connection, unit).await?;
533        // get_time_in_state only issues a D-Bus call when connection is Some;
534        // the None path logs an error and returns Ok(None) without calling out.
535        did_dbus_fetch = connection.is_some();
536    }
537
538    let entry = UnitStates {
539        active_state,
540        load_state,
541        unhealthy: is_unit_unhealthy_for_service(
542            active_state,
543            load_state,
544            is_oneshot_service,
545            config.ignore_inactive_oneshot_services,
546        ),
547        time_in_state_usecs,
548    };
549    Ok((did_dbus_fetch, Some(entry)))
550}
551
552#[tracing::instrument(level = "debug", skip(connection))]
553async fn is_oneshot_service_unit(
554    connection: &zbus::Connection,
555    unit: &ListedUnit,
556) -> Result<bool, MonitordUnitsError> {
557    is_oneshot_service_at_path(connection, &unit.unit_object_path).await
558}
559
560/// Check whether a service unit is `Type=oneshot` via its D-Bus object path.
561async fn is_oneshot_service_at_path(
562    connection: &zbus::Connection,
563    object_path: &OwnedObjectPath,
564) -> Result<bool, MonitordUnitsError> {
565    let sp = crate::dbus::zbus_service::ServiceProxy::builder(connection)
566        .cache_properties(zbus::proxy::CacheProperties::No)
567        .path(ObjectPath::from(object_path.clone()))?
568        .build()
569        .await?;
570    Ok(sp.type_().await? == "oneshot")
571}
572
573/// Check whether a service unit is `Type=oneshot`, resolving the unit name to
574/// a D-Bus object path first.
575///
576/// Used by the varlink units path, which knows unit names (from metric
577/// objects) but never sees D-Bus object paths.
578pub(crate) async fn is_oneshot_service_by_name(
579    connection: &zbus::Connection,
580    unit_name: &str,
581) -> Result<bool, MonitordUnitsError> {
582    let mp = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
583        .cache_properties(zbus::proxy::CacheProperties::No)
584        .build()
585        .await?;
586    let object_path = mp.get_unit(unit_name).await?;
587    is_oneshot_service_at_path(connection, &object_path).await
588}
589
590/// Parse a unit and add to overall counts of state, type etc.
591fn parse_unit(stats: &mut SystemdUnitStats, unit: &ListedUnit) {
592    // Count unit type
593    match unit.name.rsplit('.').next() {
594        Some("automount") => stats.automount_units += 1,
595        Some("device") => stats.device_units += 1,
596        Some("mount") => stats.mount_units += 1,
597        Some("path") => stats.path_units += 1,
598        Some("scope") => stats.scope_units += 1,
599        Some("service") => stats.service_units += 1,
600        Some("slice") => stats.slice_units += 1,
601        Some("socket") => stats.socket_units += 1,
602        Some("target") => stats.target_units += 1,
603        Some("timer") => stats.timer_units += 1,
604        unknown => debug!("Found unhandled '{:?}' unit type", unknown),
605    };
606    // Count load state
607    match unit.load_state.as_str() {
608        "loaded" => stats.loaded_units += 1,
609        "masked" => stats.masked_units += 1,
610        "not-found" => stats.not_found_units += 1,
611        _ => debug!("{} is not loaded. It's {}", unit.name, unit.load_state),
612    };
613    // Count unit status
614    match unit.active_state.as_str() {
615        "activating" => stats.activating_units += 1,
616        "active" => stats.active_units += 1,
617        "failed" => stats.failed_units += 1,
618        "inactive" => stats.inactive_units += 1,
619        unknown => debug!("Found unhandled '{}' unit state", unknown),
620    };
621    // Count jobs queued
622    if unit.job_id != 0 {
623        stats.jobs_queued += 1;
624    }
625}
626
627const TRANSIENT_DIR: &str = "/run/systemd/transient";
628
629async fn count_unit_files_by_type(path: &str) -> HashMap<String, u64> {
630    let mut dir = match tokio::fs::read_dir(path).await {
631        Ok(d) => d,
632        Err(err) => {
633            debug!("Unable to read {}: {:?}", path, err);
634            return HashMap::new();
635        }
636    };
637    let mut counts = HashMap::new();
638    loop {
639        match dir.next_entry().await {
640            Ok(Some(entry)) => {
641                let file_type = match entry.file_type().await {
642                    Ok(ft) => ft,
643                    Err(_) => continue,
644                };
645                if !file_type.is_file() {
646                    continue;
647                }
648                let name = entry.file_name();
649                let unit_type = name
650                    .to_str()
651                    .and_then(|n| n.rsplit('.').next())
652                    .unwrap_or("unknown");
653                *counts.entry(unit_type.to_string()).or_insert(0) += 1;
654            }
655            Ok(None) => break,
656            Err(err) => {
657                warn!("Error reading entry in {}: {:?}", path, err);
658                continue;
659            }
660        }
661    }
662    counts
663}
664
665fn merge_counts(target: &mut HashMap<String, u64>, source: HashMap<String, u64>) {
666    for (unit_type, count) in source {
667        *target.entry(unit_type).or_insert(0) += count;
668    }
669}
670
671/// Enumerate the per-user systemd transient directories under `{fs_root}/run/user`.
672async fn enumerate_user_transient_dirs(fs_root: &str) -> Vec<String> {
673    let user_dir = format!("{fs_root}/run/user");
674    match tokio::fs::read_dir(&user_dir).await {
675        Ok(mut entries) => {
676            let mut dirs = Vec::new();
677            loop {
678                match entries.next_entry().await {
679                    Ok(Some(entry)) => {
680                        dirs.push(format!("{}/systemd/transient", entry.path().display()));
681                    }
682                    Ok(None) => break,
683                    Err(err) => {
684                        warn!("Error reading entry in {}: {:?}", user_dir, err);
685                        continue;
686                    }
687                }
688            }
689            dirs
690        }
691        Err(err) => {
692            debug!("Unable to read {}: {:?}", user_dir, err);
693            Vec::new()
694        }
695    }
696}
697
698/// Collect unit file statistics from the filesystem.
699/// `fs_root` is prepended to all paths — empty string for the host,
700/// `/proc/<pid>/root` for containers.
701///
702/// All directory reads are issued in parallel: the three generator directories,
703/// the root transient directory, and user-dir enumeration run concurrently in a
704/// first batch; per-user transient reads run concurrently in a second batch.
705pub async fn collect_unit_files_stats(fs_root: &str) -> UnitFilesStats {
706    // Pre-bind formatted paths to extend their lifetime across the join.
707    let gen_path = format!("{fs_root}/run/systemd/generator");
708    let gen_early_path = format!("{fs_root}/run/systemd/generator.early");
709    let gen_late_path = format!("{fs_root}/run/systemd/generator.late");
710    let transient_path = format!("{fs_root}{TRANSIENT_DIR}");
711
712    // First batch: fixed paths + user dir enumeration all in parallel.
713    let (gen, gen_early, gen_late, root_transient, user_dirs) = tokio::join!(
714        count_unit_files_by_type(&gen_path),
715        count_unit_files_by_type(&gen_early_path),
716        count_unit_files_by_type(&gen_late_path),
717        count_unit_files_by_type(&transient_path),
718        enumerate_user_transient_dirs(fs_root),
719    );
720
721    let mut root_generated = HashMap::new();
722    merge_counts(&mut root_generated, gen);
723    merge_counts(&mut root_generated, gen_early);
724    merge_counts(&mut root_generated, gen_late);
725
726    // Second batch: read every user transient directory in parallel.
727    let user_transient_counts =
728        futures_util::future::join_all(user_dirs.iter().map(|d| count_unit_files_by_type(d))).await;
729
730    let mut user_transient = HashMap::new();
731    for counts in user_transient_counts {
732        merge_counts(&mut user_transient, counts);
733    }
734
735    UnitFilesStats {
736        root: UnitFilesScope {
737            generated: root_generated,
738            transient: root_transient,
739        },
740        user: UnitFilesScope {
741            generated: HashMap::new(),
742            transient: user_transient,
743        },
744    }
745}
746
747/// Owned result of one unit's concurrent D-Bus work, merged into `SystemdUnitStats`
748/// by the caller once the spawned task completes. Keeping this owned (rather than
749/// mutating `SystemdUnitStats` from multiple concurrent tasks) avoids needing a
750/// `Mutex`/`RwLock` around it.
751#[derive(Default)]
752struct PerUnitOutcome {
753    unit_name: String,
754    unit_states_entry: Option<UnitStates>,
755    state_dbus_fetch: bool,
756    service_stats_entry: Option<ServiceStats>,
757    timer_stats_entry: Option<TimerStats>,
758    duration_ms: f64,
759}
760
761/// Pull all units from dbus and count how system is setup and behaving
762#[tracing::instrument(level = "debug", skip(config, connection))]
763pub async fn parse_unit_state(
764    config: &Arc<crate::config::Config>,
765    connection: &zbus::Connection,
766    fs_root: &str,
767) -> Result<SystemdUnitStats, MonitordUnitsError> {
768    if !config.units.state_stats_allowlist.is_empty() {
769        debug!(
770            "Using unit state allowlist: {:?}",
771            config.units.state_stats_allowlist
772        );
773    }
774
775    if !config.units.state_stats_blocklist.is_empty() {
776        debug!(
777            "Using unit state blocklist: {:?}",
778            config.units.state_stats_blocklist,
779        );
780    }
781
782    let mut stats = SystemdUnitStats::default();
783
784    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
785        .cache_properties(zbus::proxy::CacheProperties::No)
786        .build()
787        .await?;
788
789    // Run filesystem collection and D-Bus list_units in parallel, timing each independently.
790    let (unit_files_result, units_result) = tokio::join!(
791        async {
792            let start = Instant::now();
793            let files = if config.units.unit_files {
794                collect_unit_files_stats(fs_root).await
795            } else {
796                UnitFilesStats::default()
797            };
798            (files, start.elapsed().as_secs_f64() * 1000.0)
799        },
800        async {
801            let start = Instant::now();
802            let units = p.list_units().await;
803            (units, start.elapsed().as_secs_f64() * 1000.0)
804        },
805    );
806    let (unit_files, unit_files_ms) = unit_files_result;
807    let (units_result, list_units_ms) = units_result;
808    stats.collection_timings.unit_files_ms = unit_files_ms;
809    stats.collection_timings.list_units_ms = list_units_ms;
810    stats.unit_files = unit_files;
811
812    let units = units_result?;
813    stats.total_units = units.len() as u64;
814
815    let per_unit_loop_start = Instant::now();
816    let mut state_dbus_fetches: u64 = 0;
817    let mut service_dbus_fetches: u64 = 0;
818    let mut timer_dbus_fetches: u64 = 0;
819
820    // Cheap synchronous unit-type/state counting first, separate from the
821    // concurrent D-Bus work below — no .await, so no reason to involve the
822    // per-unit tasks in it.
823    let listed_units: Vec<ListedUnit> = units.into_iter().map(ListedUnit::from).collect();
824    for unit in &listed_units {
825        parse_unit(&mut stats, unit);
826    }
827
828    // Bounded-concurrency D-Bus work per unit. A semaphore (rather than an
829    // unbounded join) caps how many units are in flight at once: a burst of
830    // simultaneous D-Bus calls could itself worsen host-level IPC contention
831    // on hosts where per-call latency is already elevated, which is exactly
832    // the failure mode this loop exists to avoid.
833    let semaphore = Arc::new(Semaphore::new(
834        config.units.per_unit_concurrency.max(1) as usize
835    ));
836    // Captured once, outside the loop: `JoinSet::spawn` runs each unit on a new
837    // task, and tracing spans do not cross task boundaries automatically. Without
838    // attaching this as the explicit parent below, every `unit_collect` span
839    // becomes its own unrelated root trace instead of a child of this collector.
840    let parent_span = tracing::Span::current();
841    // Host-wide memory numbers for the `memory_available` fold, read once
842    // per collection cycle and shared by every unit (rather than once per
843    // service inside `parse_service`).
844    let host_memory = crate::cgroup::read_host_memory().await;
845    let mut join_set: JoinSet<PerUnitOutcome> = JoinSet::new();
846    for unit in listed_units {
847        let semaphore = Arc::clone(&semaphore);
848        let config = Arc::clone(config);
849        let connection = connection.clone();
850        let parent_span = parent_span.clone();
851        let fs_root = fs_root.to_string();
852        join_set.spawn(async move {
853            let _permit = semaphore
854                .acquire()
855                .await
856                .expect("semaphore closed unexpectedly");
857            let unit_collect_start = Instant::now();
858            let span =
859                tracing::debug_span!(parent: &parent_span, "unit_collect", unit = %unit.name);
860            let mut outcome = async {
861                let mut outcome = PerUnitOutcome {
862                    unit_name: unit.name.clone(),
863                    ..Default::default()
864                };
865
866                // Collect per unit state stats - ActiveState + LoadState.
867                // Not collecting SubState (yet). A D-Bus error on one unit
868                // is logged and skipped rather than aborting the whole
869                // collection cycle for every other unit.
870                if config.units.state_stats {
871                    match parse_state(&unit, &config.units, Some(&connection)).await {
872                        Ok((did_fetch, entry)) => {
873                            outcome.state_dbus_fetch = did_fetch;
874                            outcome.unit_states_entry = entry;
875                        }
876                        Err(err) => {
877                            error!("Unable to get state for {}: {:?}", unit.name, err);
878                        }
879                    }
880                }
881
882                // Collect service stats
883                if config.services.contains(&unit.name) {
884                    debug!("Collecting service stats for {:?}", &unit);
885                    match parse_service(
886                        &connection,
887                        &unit.name,
888                        &unit.unit_object_path,
889                        &fs_root,
890                        host_memory,
891                    )
892                    .await
893                    {
894                        Ok(service_stats) => outcome.service_stats_entry = Some(service_stats),
895                        Err(err) => error!(
896                            "Unable to get service stats for {} {}: {:#?}",
897                            &unit.name, &unit.unit_object_path, err
898                        ),
899                    }
900                }
901
902                // Collect timer stats
903                if config.timers.enabled
904                    && unit.name.ends_with(SYSTEMD_TIMER_SUFFIX)
905                    && !config.timers.blocklist.contains(&unit.name)
906                    && (config.timers.allowlist.is_empty()
907                        || config.timers.allowlist.contains(&unit.name))
908                {
909                    match crate::timer::collect_timer_stats(&connection, &unit).await {
910                        Ok(ts) => outcome.timer_stats_entry = Some(ts),
911                        Err(err) => error!("Failed to get {} stats: {:#?}", &unit.name, err),
912                    }
913                }
914
915                outcome
916            }
917            .instrument(span)
918            .await;
919
920            outcome.duration_ms = unit_collect_start.elapsed().as_secs_f64() * 1000.0;
921            outcome
922        });
923    }
924
925    let mut slowest_units: Vec<(String, f64)> = Vec::new();
926    while let Some(res) = join_set.join_next().await {
927        let outcome = match res {
928            Ok(outcome) => outcome,
929            Err(err) => {
930                error!("Per-unit collection task failed to join: {:?}", err);
931                continue;
932            }
933        };
934        if let Some(entry) = outcome.unit_states_entry {
935            stats.unit_states.insert(outcome.unit_name.clone(), entry);
936        }
937        if outcome.state_dbus_fetch {
938            state_dbus_fetches += 1;
939        }
940        if let Some(service_stats) = outcome.service_stats_entry {
941            stats
942                .service_stats
943                .insert(outcome.unit_name.clone(), service_stats);
944            service_dbus_fetches += 1;
945        }
946        if let Some(ts) = outcome.timer_stats_entry {
947            if ts.persistent {
948                stats.timer_persistent_units += 1;
949            }
950            if ts.remain_after_elapse {
951                stats.timer_remain_after_elapse += 1;
952            }
953            stats.timer_stats.insert(outcome.unit_name.clone(), ts);
954            timer_dbus_fetches += 1;
955        }
956        if config.units.slowest_units_count > 0 {
957            slowest_units.push((outcome.unit_name, outcome.duration_ms));
958        }
959    }
960
961    if config.units.slowest_units_count > 0 {
962        slowest_units.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
963        slowest_units.truncate(config.units.slowest_units_count as usize);
964        stats.collection_timings.slowest_units = slowest_units;
965    }
966
967    let per_unit_loop_elapsed = per_unit_loop_start.elapsed();
968    stats.collection_timings.per_unit_loop_ms = per_unit_loop_elapsed.as_secs_f64() * 1000.0;
969    stats.collection_timings.state_dbus_fetches = state_dbus_fetches;
970    stats.collection_timings.service_dbus_fetches = service_dbus_fetches;
971    stats.collection_timings.timer_dbus_fetches = timer_dbus_fetches;
972
973    debug!("unit stats: {:?}", stats);
974    Ok(stats)
975}
976
977/// Async wrapper that can update unit stats when passed a locked struct.
978/// `fs_root` is prepended to filesystem paths for unit file stats —
979/// empty string for the host, `/proc/<pid>/root` for containers.
980///
981/// The whole (potentially hundreds-of-D-Bus-calls) collection runs before the
982/// write lock is taken, not while holding it: `locked_machine_stats` is
983/// shared by every collector, so holding it across the entire per-unit loop
984/// would block every other collector's own (often much quicker) write until
985/// this collection finished.
986pub async fn update_unit_stats(
987    config: Arc<crate::config::Config>,
988    connection: zbus::Connection,
989    locked_machine_stats: Arc<RwLock<MachineStats>>,
990    fs_root: String,
991) -> anyhow::Result<()> {
992    let units_stats = parse_unit_state(&config, &connection, &fs_root).await;
993    let mut machine_stats = locked_machine_stats.write().await;
994    match units_stats {
995        Ok(units_stats) => machine_stats.units = units_stats,
996        Err(err) => error!("units stats failed: {:?}", err),
997    }
998    Ok(())
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use std::collections::HashSet;
1005    use strum::IntoEnumIterator;
1006
1007    fn get_unit_file() -> ListedUnit {
1008        ListedUnit {
1009            name: String::from("apport-autoreport.timer"),
1010            description: String::from(
1011                "Process error reports when automatic reporting is enabled (timer based)",
1012            ),
1013            load_state: String::from("loaded"),
1014            active_state: String::from("inactive"),
1015            sub_state: String::from("dead"),
1016            follow_unit: String::from(""),
1017            unit_object_path: ObjectPath::try_from(
1018                "/org/freedesktop/systemd1/unit/apport_2dautoreport_2etimer",
1019            )
1020            .expect("Unable to make an object path")
1021            .into(),
1022            job_id: 0,
1023            job_type: String::from(""),
1024            job_object_path: ObjectPath::try_from("/").unwrap().into(),
1025        }
1026    }
1027
1028    #[tokio::test]
1029    async fn test_state_parse() -> Result<(), MonitordUnitsError> {
1030        let test_unit_name = String::from("apport-autoreport.timer");
1031        let expected_stats = SystemdUnitStats {
1032            activating_units: 0,
1033            active_units: 0,
1034            automount_units: 0,
1035            device_units: 0,
1036            failed_units: 0,
1037            inactive_units: 0,
1038            jobs_queued: 0,
1039            loaded_units: 0,
1040            masked_units: 0,
1041            mount_units: 0,
1042            not_found_units: 0,
1043            path_units: 0,
1044            scope_units: 0,
1045            service_units: 0,
1046            slice_units: 0,
1047            socket_units: 0,
1048            target_units: 0,
1049            timer_units: 0,
1050            timer_persistent_units: 0,
1051            timer_remain_after_elapse: 0,
1052            total_units: 0,
1053            unit_files: UnitFilesStats::default(),
1054            service_stats: HashMap::new(),
1055            timer_stats: HashMap::new(),
1056            unit_states: HashMap::from([(
1057                test_unit_name.clone(),
1058                UnitStates {
1059                    active_state: SystemdUnitActiveState::inactive,
1060                    load_state: SystemdUnitLoadState::loaded,
1061                    unhealthy: true,
1062                    time_in_state_usecs: None,
1063                },
1064            )]),
1065            collection_timings: UnitsCollectionTimings::default(),
1066        };
1067        let mut stats = SystemdUnitStats::default();
1068        let systemd_unit = get_unit_file();
1069        let mut config = crate::config::UnitsConfig::default();
1070
1071        // Test no allow list or blocklist; with connection: None, parse_state
1072        // takes the no-op path inside get_time_in_state and returns false.
1073        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
1074        if let Some(entry) = entry {
1075            stats.unit_states.insert(systemd_unit.name.clone(), entry);
1076        }
1077        assert_eq!(expected_stats, stats);
1078        assert!(!did_fetch);
1079
1080        // Create an allow list
1081        config.state_stats_allowlist = HashSet::from([test_unit_name.clone()]);
1082
1083        // test no blocklist and only allow list - Should equal the same as no lists above
1084        let mut allowlist_stats = SystemdUnitStats::default();
1085        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
1086        if let Some(entry) = entry {
1087            allowlist_stats
1088                .unit_states
1089                .insert(systemd_unit.name.clone(), entry);
1090        }
1091        assert_eq!(expected_stats, allowlist_stats);
1092        assert!(!did_fetch);
1093
1094        // Now add a blocklist
1095        config.state_stats_blocklist = HashSet::from([test_unit_name]);
1096
1097        // test blocklist with allow list (show it's preferred)
1098        let mut blocklist_stats = SystemdUnitStats::default();
1099        let expected_blocklist_stats = SystemdUnitStats::default();
1100        let (did_fetch, entry) = parse_state(&systemd_unit, &config, None).await?;
1101        if let Some(entry) = entry {
1102            blocklist_stats
1103                .unit_states
1104                .insert(systemd_unit.name.clone(), entry);
1105        }
1106        assert_eq!(expected_blocklist_stats, blocklist_stats);
1107        // Blocklist short-circuit must NOT count as a D-Bus fetch.
1108        assert!(!did_fetch);
1109        Ok(())
1110    }
1111
1112    #[test]
1113    fn test_compute_time_in_state() {
1114        // Normal case: elapsed since the recorded change.
1115        assert_eq!(
1116            compute_time_in_state(1_700_000_010_000_000, 1_700_000_000_000_000),
1117            Some(10_000_000)
1118        );
1119        // Zero timestamp (never changed, or failed lookup zeroed by the
1120        // caller): unknown rather than a bogus huge elapsed time.
1121        assert_eq!(compute_time_in_state(1_700_000_010_000_000, 0), None);
1122        // Future timestamp (clock skew): saturates to 0 instead of underflowing.
1123        assert_eq!(compute_time_in_state(100, 200), Some(0));
1124    }
1125
1126    #[test]
1127    fn test_unit_parse() {
1128        let expected_stats = SystemdUnitStats {
1129            activating_units: 0,
1130            active_units: 0,
1131            automount_units: 0,
1132            device_units: 0,
1133            failed_units: 0,
1134            inactive_units: 1,
1135            jobs_queued: 0,
1136            loaded_units: 1,
1137            masked_units: 0,
1138            mount_units: 0,
1139            not_found_units: 0,
1140            path_units: 0,
1141            scope_units: 0,
1142            service_units: 0,
1143            slice_units: 0,
1144            socket_units: 0,
1145            target_units: 0,
1146            timer_units: 1,
1147            timer_persistent_units: 0,
1148            timer_remain_after_elapse: 0,
1149            total_units: 0,
1150            unit_files: UnitFilesStats::default(),
1151            service_stats: HashMap::new(),
1152            timer_stats: HashMap::new(),
1153            unit_states: HashMap::new(),
1154            collection_timings: UnitsCollectionTimings::default(),
1155        };
1156        let mut stats = SystemdUnitStats::default();
1157        let systemd_unit = get_unit_file();
1158        parse_unit(&mut stats, &systemd_unit);
1159        assert_eq!(expected_stats, stats);
1160    }
1161
1162    #[test]
1163    fn test_unit_parse_activating() {
1164        let mut activating_unit = get_unit_file();
1165        activating_unit.active_state = String::from("activating");
1166        let mut stats = SystemdUnitStats::default();
1167        parse_unit(&mut stats, &activating_unit);
1168        assert_eq!(stats.activating_units, 1);
1169        assert_eq!(stats.active_units, 0);
1170        assert_eq!(stats.inactive_units, 0);
1171    }
1172
1173    #[test]
1174    fn test_iterators() {
1175        assert!(SystemdUnitActiveState::iter().collect::<Vec<_>>().len() > 0);
1176        assert!(SystemdUnitLoadState::iter().collect::<Vec<_>>().len() > 0);
1177    }
1178
1179    #[tokio::test]
1180    async fn test_count_unit_files_by_type() {
1181        let dir = tempfile::tempdir().expect("Unable to create temp dir");
1182        let path = dir.path();
1183
1184        std::fs::write(path.join("sshd.service"), "").unwrap();
1185        std::fs::write(path.join("nginx.service"), "").unwrap();
1186        std::fs::write(path.join("boot.mount"), "").unwrap();
1187        std::fs::write(path.join("swap.swap"), "").unwrap();
1188        std::fs::create_dir(path.join("multi-user.target.wants")).unwrap();
1189
1190        let counts = count_unit_files_by_type(path.to_str().unwrap()).await;
1191        assert_eq!(counts.get("service"), Some(&2));
1192        assert_eq!(counts.get("mount"), Some(&1));
1193        assert_eq!(counts.get("swap"), Some(&1));
1194        assert_eq!(counts.get("wants"), None);
1195        assert_eq!(counts.len(), 3);
1196    }
1197
1198    #[tokio::test]
1199    async fn test_count_unit_files_by_type_nonexistent_dir() {
1200        let counts = count_unit_files_by_type("/nonexistent/path").await;
1201        assert!(counts.is_empty());
1202    }
1203
1204    #[tokio::test]
1205    async fn test_collect_unit_files_stats_with_fs_root() {
1206        let root = tempfile::tempdir().expect("Unable to create temp dir");
1207        let root_path = root.path();
1208
1209        let gen_dir = root_path.join("run/systemd/generator");
1210        std::fs::create_dir_all(&gen_dir).unwrap();
1211        std::fs::write(gen_dir.join("boot.mount"), "").unwrap();
1212        std::fs::write(gen_dir.join("swap.swap"), "").unwrap();
1213
1214        let transient_dir = root_path.join("run/systemd/transient");
1215        std::fs::create_dir_all(&transient_dir).unwrap();
1216        std::fs::write(transient_dir.join("run-thing.service"), "").unwrap();
1217
1218        let user_transient = root_path.join("run/user/1000/systemd/transient");
1219        std::fs::create_dir_all(&user_transient).unwrap();
1220        std::fs::write(user_transient.join("app-code.scope"), "").unwrap();
1221        std::fs::write(user_transient.join("app-term.scope"), "").unwrap();
1222
1223        let stats = collect_unit_files_stats(root_path.to_str().unwrap()).await;
1224        assert_eq!(stats.root.generated.get("mount"), Some(&1));
1225        assert_eq!(stats.root.generated.get("swap"), Some(&1));
1226        assert_eq!(stats.root.transient.get("service"), Some(&1));
1227        assert_eq!(stats.user.transient.get("scope"), Some(&2));
1228        assert!(stats.user.generated.is_empty());
1229    }
1230}