1use crate::metrics::CpuMetrics;
2use procfs::prelude::*;
3use procfs::process::all_processes;
4use procfs::{CpuTime, KernelStats};
5use std::collections::{HashMap, HashSet};
6use std::time::Instant;
7
8type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
16enum CpuSource {
17 CgroupV2,
19 CgroupV1,
21 ProcStat,
23}
24
25impl CpuSource {
26 fn is_cgroup(self) -> bool {
27 !matches!(self, CpuSource::ProcStat)
28 }
29}
30
31#[derive(Debug, Clone, Copy)]
33struct CfsQuota {
34 max_cores: Option<f64>,
36}
37
38#[allow(clippy::collapsible_if)]
41fn detect_cpu_source() -> CpuSource {
42 if let Ok(contents) = std::fs::read_to_string("/sys/fs/cgroup/cpu.stat") {
44 if contents.contains("usage_usec") {
45 return CpuSource::CgroupV2;
46 }
47 }
48 for path in &[
50 "/sys/fs/cgroup/cpuacct/cpuacct.usage",
51 "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage",
52 "/sys/fs/cgroup/cpu/cpuacct.usage",
53 ] {
54 if std::fs::read_to_string(path).is_ok() {
55 return CpuSource::CgroupV1;
56 }
57 }
58 CpuSource::ProcStat
59}
60
61#[allow(clippy::collapsible_if)]
63fn detect_cfs_quota() -> CfsQuota {
64 if let Ok(contents) = std::fs::read_to_string("/sys/fs/cgroup/cpu.max") {
66 let parts: Vec<&str> = contents.split_whitespace().collect();
67 if parts.len() == 2 && parts[0] != "max" {
68 if let (Ok(quota), Ok(period)) = (parts[0].parse::<f64>(), parts[1].parse::<f64>()) {
69 if period > 0.0 {
70 return CfsQuota {
71 max_cores: Some(quota / period),
72 };
73 }
74 }
75 }
76 }
77 for prefix in &[
79 "/sys/fs/cgroup/cpu",
80 "/sys/fs/cgroup/cpu,cpuacct",
81 "/sys/fs/cgroup/cpuacct",
82 ] {
83 let quota_path = format!("{}/cpu.cfs_quota_us", prefix);
84 let period_path = format!("{}/cpu.cfs_period_us", prefix);
85 if let (Ok(q_str), Ok(p_str)) = (
86 std::fs::read_to_string("a_path),
87 std::fs::read_to_string(&period_path),
88 ) {
89 if let (Ok(quota), Ok(period)) =
90 (q_str.trim().parse::<i64>(), p_str.trim().parse::<i64>())
91 {
92 if quota > 0 && period > 0 {
94 return CfsQuota {
95 max_cores: Some(quota as f64 / period as f64),
96 };
97 }
98 }
99 }
100 }
101 CfsQuota { max_cores: None }
102}
103
104fn read_cgroupv2_usage_usec() -> Option<u64> {
106 let contents = std::fs::read_to_string("/sys/fs/cgroup/cpu.stat").ok()?;
107 for line in contents.lines() {
108 if let Some(val) = line.strip_prefix("usage_usec ") {
109 return val.trim().parse().ok();
110 }
111 }
112 None
113}
114
115#[allow(clippy::collapsible_if)]
117fn read_cgroupv1_usage_ns() -> Option<u64> {
118 for path in &[
119 "/sys/fs/cgroup/cpuacct/cpuacct.usage",
120 "/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage",
121 "/sys/fs/cgroup/cpu/cpuacct.usage",
122 ] {
123 if let Ok(contents) = std::fs::read_to_string(path)
124 && let Ok(val) = contents.trim().parse()
125 {
126 return Some(val);
127 }
128 }
129 None
130}
131
132fn read_cgroup_usage_secs(source: CpuSource) -> Option<f64> {
135 match source {
136 CpuSource::CgroupV2 => read_cgroupv2_usage_usec().map(|usec| usec as f64 / 1_000_000.0),
137 CpuSource::CgroupV1 => read_cgroupv1_usage_ns().map(|ns| ns as f64 / 1_000_000_000.0),
138 CpuSource::ProcStat => None,
139 }
140}
141
142fn cpu_total(c: &CpuTime) -> u64 {
147 c.user
148 + c.nice
149 + c.system
150 + cpu_idle(c)
151 + c.irq.unwrap_or(0)
152 + c.softirq.unwrap_or(0)
153 + c.steal.unwrap_or(0)
154}
155
156fn cpu_idle(c: &CpuTime) -> u64 {
157 c.idle + c.iowait.unwrap_or(0)
158}
159
160fn core_util_pct(prev: &CpuTime, curr: &CpuTime) -> f64 {
162 util_pct_from_ticks(
163 cpu_total(prev),
164 cpu_idle(prev),
165 cpu_total(curr),
166 cpu_idle(curr),
167 )
168 .clamp(0.0, 100.0)
169}
170
171fn aggregate_util_cores(prev: &CpuTime, curr: &CpuTime, n_cores: usize) -> f64 {
174 util_pct_from_ticks(
175 cpu_total(prev),
176 cpu_idle(prev),
177 cpu_total(curr),
178 cpu_idle(curr),
179 ) / 100.0
180 * n_cores as f64
181}
182
183fn util_pct_from_ticks(prev_total: u64, prev_idle: u64, curr_total: u64, curr_idle: u64) -> f64 {
187 let delta_total = curr_total.saturating_sub(prev_total) as f64;
188 let delta_idle = curr_idle.saturating_sub(prev_idle) as f64;
189 if delta_total == 0.0 {
190 return 0.0;
191 }
192 (delta_total - delta_idle) / delta_total * 100.0
193}
194
195fn process_tree_ticks(root_pid: i32) -> HashMap<i32, (u64, u64)> {
203 let all: Vec<_> = match all_processes() {
205 Ok(iter) => iter.filter_map(|r| r.ok()).collect(),
206 Err(_) => return HashMap::new(),
207 };
208
209 let mut children: HashMap<i32, Vec<i32>> = HashMap::new();
223 let ticks_for: HashMap<i32, (u64, u64)> = all
224 .iter()
225 .filter_map(|proc| {
226 proc.stat().ok().map(|s| {
227 children.entry(s.ppid).or_default().push(proc.pid);
228 let user = s.utime + u64::try_from(s.cutime).unwrap_or(0);
229 let system = s.stime + u64::try_from(s.cstime).unwrap_or(0);
230 (proc.pid, (user, system))
231 })
232 })
233 .collect();
234
235 let mut result = HashMap::new();
237 let mut queue = vec![root_pid];
238 while let Some(pid) = queue.pop() {
239 if let Some(&ticks) = ticks_for.get(&pid) {
240 result.insert(pid, ticks);
241 }
242 if let Some(kids) = children.get(&pid) {
243 queue.extend(kids);
244 }
245 }
246 result
247}
248
249fn process_tree_memory_mib(pids: &[i32]) -> (u64, u64) {
251 const BYTES_PER_MIB: u64 = 1024 * 1024;
252
253 let mut total_pss_bytes = 0u64;
254 let mut total_rss_bytes = 0u64;
255
256 for &pid in pids {
257 let Some(process) = procfs::process::Process::new(pid).ok() else {
258 continue;
259 };
260
261 let Ok(rollup) = process.smaps_rollup() else {
262 continue;
263 };
264
265 let Some(memory_map) = rollup.memory_map_rollup.0.first() else {
267 continue;
268 };
269
270 if let Some(&pss_bytes) = memory_map.extension.map.get("Pss") {
271 total_pss_bytes += pss_bytes;
272 }
273
274 if let Some(&rss_bytes) = memory_map.extension.map.get("Rss") {
275 total_rss_bytes += rss_bytes;
276 }
277 }
278
279 (
280 total_pss_bytes / BYTES_PER_MIB,
281 total_rss_bytes / BYTES_PER_MIB,
282 )
283}
284
285fn process_tree_io(pids: &[i32]) -> HashMap<i32, (u64, u64)> {
290 pids.iter()
291 .filter_map(|&pid| {
292 let io = procfs::process::Process::new(pid).ok()?.io().ok()?;
293 Some((pid, (io.read_bytes, io.write_bytes)))
294 })
295 .collect()
296}
297
298struct Snapshot {
303 total: CpuTime,
305 per_core: Vec<CpuTime>,
307 instant: Instant,
310 cgroup_usage_secs: Option<f64>,
312 proc_ticks: HashMap<i32, (u64, u64)>,
315 proc_io: HashMap<i32, (u64, u64)>,
318}
319
320pub struct CpuCollector {
321 pid: Option<i32>,
323 prev: Option<Snapshot>,
324 cpu_source: CpuSource,
326 cfs_quota: CfsQuota,
328 effective_cores: f64,
331 carried_forward: HashSet<i32>,
335 aggregate_cpu_steal: bool,
337}
338
339impl CpuCollector {
340 pub fn new(pid: Option<i32>, aggregate_cpu_steal: bool) -> Self {
341 let cpu_source = detect_cpu_source();
342 let cfs_quota = detect_cfs_quota();
343
344 let physical_cores = KernelStats::current()
346 .map(|s| s.cpu_time.len())
347 .unwrap_or(1) as f64;
348 let effective_cores = match cfs_quota.max_cores {
349 Some(quota) => physical_cores.min(quota),
350 None => physical_cores,
351 };
352
353 Self {
354 pid,
355 prev: None,
356 cpu_source,
357 cfs_quota,
358 effective_cores,
359 carried_forward: HashSet::new(),
360 aggregate_cpu_steal,
361 }
362 }
363
364 pub fn set_tracked_pid(&mut self, pid: Option<i32>) {
366 self.pid = pid;
367 }
368
369 pub fn collect(&mut self) -> Result<CpuMetrics> {
370 let tps = procfs::ticks_per_second() as f64;
371 let process_count = self.read_process_count();
372
373 let stats = KernelStats::current()?;
375 let cgroup_usage_secs = read_cgroup_usage_secs(self.cpu_source);
376 let proc_ticks = self.read_process_ticks();
377 let now = Instant::now();
378
379 let proc_io = self.read_process_io(&proc_ticks);
380 let (process_pss_mib, process_rss_mib) = self.read_process_memory(&proc_ticks);
381
382 let mut curr = Snapshot {
383 total: stats.total,
384 per_core: stats.cpu_time,
385 instant: now,
386 cgroup_usage_secs,
387 proc_ticks,
388 proc_io,
389 };
390
391 let metrics = match &self.prev {
392 None => {
393 self.build_first_metrics(&curr, process_count, process_pss_mib, process_rss_mib)
394 }
395 Some(prev) => self.build_subsequent_metrics_with_deltas(
396 prev,
397 &mut curr,
398 process_count,
399 tps,
400 process_pss_mib,
401 process_rss_mib,
402 ),
403 };
404
405 self.carry_forward_entries(&mut curr);
406 self.prev = Some(curr);
407 Ok(metrics)
408 }
409
410 fn read_process_count(&self) -> u32 {
411 let Ok(proc_dir) = std::fs::read_dir("/proc") else {
412 return 0;
413 };
414
415 let process_count = proc_dir
416 .filter_map(|entry| entry.ok())
417 .filter(|e| Self::is_pid_directory(e))
418 .count();
419
420 u32::try_from(process_count).unwrap_or(0)
421 }
422
423 fn is_pid_directory(entry: &std::fs::DirEntry) -> bool {
424 entry
425 .file_name()
426 .to_string_lossy()
427 .chars()
428 .all(|c| c.is_ascii_digit())
429 }
430
431 fn read_process_ticks(&self) -> HashMap<i32, (u64, u64)> {
432 match self.pid {
433 Some(root) => process_tree_ticks(root),
434 None => HashMap::new(),
435 }
436 }
437
438 fn read_process_io(&self, proc_ticks: &HashMap<i32, (u64, u64)>) -> HashMap<i32, (u64, u64)> {
439 if self.pid.is_some() {
440 let pids: Vec<i32> = proc_ticks.keys().copied().collect();
441 process_tree_io(&pids)
442 } else {
443 HashMap::new()
444 }
445 }
446
447 fn read_process_memory(
448 &self,
449 proc_ticks: &HashMap<i32, (u64, u64)>,
450 ) -> (Option<u64>, Option<u64>) {
451 if self.pid.is_some() {
452 let pids: Vec<i32> = proc_ticks.keys().copied().collect();
453 let (pss, rss) = process_tree_memory_mib(&pids);
454 (Some(pss), Some(rss))
455 } else {
456 (None, None)
457 }
458 }
459
460 fn build_first_metrics(
461 &self,
462 curr: &Snapshot,
463 process_count: u32,
464 process_pss_mib: Option<u64>,
465 process_rss_mib: Option<u64>,
466 ) -> CpuMetrics {
467 CpuMetrics {
468 utilization_pct: 0.0,
469 cgroup_utilization_pct: curr
470 .cgroup_usage_secs
471 .filter(|_| self.cpu_source.is_cgroup())
472 .map(|_| 0.0),
473 cgroup_usage_secs: curr
474 .cgroup_usage_secs
475 .filter(|_| self.cpu_source.is_cgroup())
476 .map(|_| 0.0),
477 per_core_pct: vec![0.0; curr.per_core.len()],
478 utime_secs: 0.0,
479 stime_secs: 0.0,
480 steal_time_secs: 0.0,
481 steal_time_pct: 0.0,
482 per_core_steal_time_pct: if self.aggregate_cpu_steal {
483 vec![]
484 } else {
485 vec![0.0; curr.per_core.len()]
486 },
487 process_count,
488 process_cores_used: self.pid.map(|_| 0.0),
489 process_child_count: self
490 .pid
491 .map(|_| u32::try_from(curr.proc_ticks.len().saturating_sub(1)).unwrap_or(0)),
492 process_utime_secs: self.pid.map(|_| 0.0),
493 process_stime_secs: self.pid.map(|_| 0.0),
494 process_pss_mib,
495 process_rss_mib,
496 process_disk_read_bytes: self.pid.map(|_| 0),
497 process_disk_write_bytes: self.pid.map(|_| 0),
498 process_gpu_usage: None,
499 process_gpu_vram_mib: None,
500 process_gpu_utilized: None,
501 process_tree_pids: curr.proc_ticks.keys().copied().collect(),
502 }
503 }
504
505 fn build_subsequent_metrics_with_deltas(
506 &self,
507 prev: &Snapshot,
508 curr: &mut Snapshot,
509 process_count: u32,
510 tps: f64,
511 process_pss_mib: Option<u64>,
512 process_rss_mib: Option<u64>,
513 ) -> CpuMetrics {
514 let n_cores = curr.per_core.len();
515 let elapsed = (curr.instant - prev.instant).as_secs_f64().max(0.001);
516
517 let (utime_secs, stime_secs) = self.calculate_system_cpu_deltas(prev, curr, tps);
518 let per_core_pct = self.calculate_per_core_utilization(prev, curr);
519 let utilization_pct = aggregate_util_cores(&prev.total, &curr.total, n_cores);
520 let (cgroup_usage_secs, cgroup_utilization_pct) =
521 self.calculate_cgroup_usage(prev, curr, elapsed);
522 let (exited_utime, exited_stime) = self.calculate_exited_child_ticks(prev, curr);
523
524 let process_utime_secs = self.calculate_process_utime_delta(prev, curr, exited_utime, tps);
525 let process_stime_secs = self.calculate_process_stime_delta(prev, curr, exited_stime, tps);
526
527 let steal_time_secs = self.calculate_steal_time_secs(prev, curr, tps);
528 let steal_time_pct = self.calculate_steal_time_pct(prev, curr);
529 let per_core_steal_time_pct = if self.aggregate_cpu_steal {
530 vec![]
531 } else {
532 self.calculate_per_core_steal_pct(prev, curr)
533 };
534
535 let process_cores_used = self.calculate_process_cores_used_with_caps(
536 &process_utime_secs,
537 &process_stime_secs,
538 prev,
539 curr,
540 elapsed,
541 n_cores,
542 );
543
544 let process_disk_read_bytes = self.calculate_disk_read_delta(prev, curr);
545 let process_disk_write_bytes = self.calculate_disk_write_delta(prev, curr);
546
547 CpuMetrics {
548 utilization_pct,
549 cgroup_utilization_pct,
550 cgroup_usage_secs,
551 per_core_pct,
552 utime_secs,
553 stime_secs,
554 steal_time_secs,
555 steal_time_pct,
556 per_core_steal_time_pct,
557 process_count,
558 process_cores_used,
559 process_child_count: self
560 .pid
561 .map(|_| u32::try_from(curr.proc_ticks.len().saturating_sub(1)).unwrap_or(0)),
562 process_utime_secs,
563 process_stime_secs,
564 process_pss_mib,
565 process_rss_mib,
566 process_disk_read_bytes,
567 process_disk_write_bytes,
568 process_gpu_usage: None,
569 process_gpu_vram_mib: None,
570 process_gpu_utilized: None,
571 process_tree_pids: curr.proc_ticks.keys().copied().collect(),
572 }
573 }
574
575 fn calculate_system_cpu_deltas(
576 &self,
577 prev: &Snapshot,
578 curr: &Snapshot,
579 tps: f64,
580 ) -> (f64, f64) {
581 let utime_secs = (curr.total.user + curr.total.nice)
582 .saturating_sub(prev.total.user + prev.total.nice) as f64
583 / tps;
584 let stime_secs = curr.total.system.saturating_sub(prev.total.system) as f64 / tps;
585 (utime_secs, stime_secs)
586 }
587
588 fn calculate_per_core_utilization(&self, prev: &Snapshot, curr: &Snapshot) -> Vec<f64> {
589 prev.per_core
590 .iter()
591 .zip(curr.per_core.iter())
592 .map(|(p, c)| core_util_pct(p, c))
593 .collect()
594 }
595
596 fn calculate_cgroup_usage(
597 &self,
598 prev: &Snapshot,
599 curr: &Snapshot,
600 elapsed: f64,
601 ) -> (Option<f64>, Option<f64>) {
602 match (curr.cgroup_usage_secs, prev.cgroup_usage_secs) {
603 (Some(curr_cg), Some(prev_cg)) => {
604 let delta = (curr_cg - prev_cg).max(0.0);
605 let cores_used = delta / elapsed;
606 (Some(delta), Some(cores_used.min(self.effective_cores)))
607 }
608 _ => (None, None),
609 }
610 }
611
612 fn calculate_exited_child_ticks(&self, prev: &Snapshot, curr: &Snapshot) -> (u64, u64) {
614 if self.pid.is_none() {
615 return (0, 0);
616 }
617
618 prev.proc_ticks
619 .iter()
620 .filter(|(pid, _)| !curr.proc_ticks.contains_key(*pid))
621 .fold((0, 0), |(user_sum, sys_sum), (_, &(user, sys))| {
622 (user_sum + user, sys_sum + sys)
623 })
624 }
625 fn calculate_process_utime_delta(
626 &self,
627 prev: &Snapshot,
628 curr: &Snapshot,
629 exited_utime: u64,
630 tps: f64,
631 ) -> Option<f64> {
632 if self.pid.is_none() {
633 return None;
634 }
635
636 let raw: u64 = curr
637 .proc_ticks
638 .iter()
639 .map(|(pid, &(cu, _))| {
640 let pu = prev.proc_ticks.get(pid).map(|&(u, _)| u).unwrap_or(cu);
641 cu.saturating_sub(pu)
642 })
643 .sum();
644
645 let adjusted = if exited_utime <= raw {
646 raw - exited_utime
647 } else {
648 raw
649 };
650
651 Some(adjusted as f64 / tps)
652 }
653
654 fn calculate_process_stime_delta(
655 &self,
656 prev: &Snapshot,
657 curr: &Snapshot,
658 exited_stime: u64,
659 tps: f64,
660 ) -> Option<f64> {
661 if self.pid.is_none() {
662 return None;
663 }
664
665 let mut raw: u64 = 0;
666 for (pid, &(_, cs)) in &curr.proc_ticks {
667 let ps = prev.proc_ticks.get(pid).map(|&(_, s)| s).unwrap_or(cs);
668 raw += cs.saturating_sub(ps);
669 }
670
671 let adjusted = if exited_stime <= raw {
672 raw - exited_stime
673 } else {
674 raw
675 };
676
677 Some(adjusted as f64 / tps)
678 }
679
680 fn calculate_steal_time_secs(&self, prev: &Snapshot, curr: &Snapshot, tps: f64) -> f64 {
681 let curr_steal = curr.total.steal.unwrap_or(0);
682 let prev_steal = prev.total.steal.unwrap_or(0);
683
684 curr_steal.saturating_sub(prev_steal) as f64 / tps
685 }
686
687 fn calculate_steal_time_pct(&self, prev: &Snapshot, curr: &Snapshot) -> f64 {
688 let prev_total = cpu_total(&prev.total);
689 let curr_total = cpu_total(&curr.total);
690 let prev_steal = prev.total.steal.unwrap_or(0);
691 let curr_steal = curr.total.steal.unwrap_or(0);
692
693 let delta_total = curr_total.saturating_sub(prev_total) as f64;
694 let delta_steal = curr_steal.saturating_sub(prev_steal) as f64;
695
696 if delta_total == 0.0 {
697 0.0
698 } else {
699 (delta_steal / delta_total * 100.0).clamp(0.0, 100.0)
700 }
701 }
702
703 fn calculate_per_core_steal_pct(&self, prev: &Snapshot, curr: &Snapshot) -> Vec<f64> {
704 prev.per_core
705 .iter()
706 .zip(curr.per_core.iter())
707 .map(|(p, c)| {
708 let p_total = cpu_total(p);
709 let c_total = cpu_total(c);
710 let p_steal = p.steal.unwrap_or(0);
711 let c_steal = c.steal.unwrap_or(0);
712
713 let delta_total = c_total.saturating_sub(p_total) as f64;
714 let delta_steal = c_steal.saturating_sub(p_steal) as f64;
715
716 if delta_total == 0.0 {
717 0.0
718 } else {
719 (delta_steal / delta_total * 100.0).clamp(0.0, 100.0)
720 }
721 })
722 .collect()
723 }
724
725 fn calculate_process_cores_used_with_caps(
726 &self,
727 process_utime_secs: &Option<f64>,
728 process_stime_secs: &Option<f64>,
729 prev: &Snapshot,
730 curr: &Snapshot,
731 elapsed: f64,
732 n_cores: usize,
733 ) -> Option<f64> {
734 match (self.pid, process_utime_secs, process_stime_secs) {
735 (Some(_), Some(u), Some(s)) => {
736 let raw_cores = ((u + s) / elapsed).max(0.0);
737
738 let sys_total_delta = cpu_total(&curr.total).saturating_sub(cpu_total(&prev.total));
740 let sys_idle_delta = cpu_idle(&curr.total).saturating_sub(cpu_idle(&prev.total));
741 let sys_busy_secs = if sys_total_delta > 0 {
742 (sys_total_delta - sys_idle_delta.min(sys_total_delta)) as f64
743 / procfs::ticks_per_second() as f64
744 } else {
745 f64::MAX
746 };
747 let tick_ratio_cap = sys_busy_secs / elapsed;
748
749 let quota_cap = self.cfs_quota.max_cores.unwrap_or(n_cores as f64);
751
752 Some(raw_cores.min(tick_ratio_cap).min(quota_cap))
753 }
754 _ => None,
755 }
756 }
757
758 fn calculate_disk_read_delta(&self, prev: &Snapshot, curr: &Snapshot) -> Option<u64> {
759 self.pid.map(|_| {
760 curr.proc_io
761 .iter()
762 .map(|(pid, &(cr, _))| {
763 let pr = prev.proc_io.get(pid).map(|&(r, _)| r).unwrap_or(cr);
764 cr.saturating_sub(pr)
765 })
766 .sum()
767 })
768 }
769
770 fn calculate_disk_write_delta(&self, prev: &Snapshot, curr: &Snapshot) -> Option<u64> {
771 self.pid.map(|_| {
772 curr.proc_io
773 .iter()
774 .map(|(pid, &(_, cw))| {
775 let pw = prev.proc_io.get(pid).map(|&(_, w)| w).unwrap_or(cw);
776 cw.saturating_sub(pw)
777 })
778 .sum()
779 })
780 }
781
782 fn carry_forward_entries(&mut self, curr: &mut Snapshot) {
784 let mut new_carried = HashSet::new();
785
786 if let Some(ref prev_snap) = self.prev {
787 for (&pid, &ticks) in &prev_snap.proc_ticks {
789 if !curr.proc_ticks.contains_key(&pid) && !self.carried_forward.contains(&pid) {
790 curr.proc_ticks.insert(pid, ticks);
791 new_carried.insert(pid);
792 }
793 }
794
795 for (&pid, &io) in &prev_snap.proc_io {
797 if !curr.proc_io.contains_key(&pid) && !self.carried_forward.contains(&pid) {
798 curr.proc_io.insert(pid, io);
799 }
800 }
801 }
802
803 self.carried_forward = new_carried;
804 }
805}
806
807#[cfg(test)]
812mod tests {
813 use super::*;
814
815 #[test]
823 fn test_util_pct_all_idle_is_zero() {
824 assert_eq!(util_pct_from_ticks(0, 0, 1600, 1600), 0.0);
826 }
827
828 #[test]
829 fn test_util_pct_fully_busy_is_100() {
830 let pct = util_pct_from_ticks(0, 0, 1600, 0);
832 assert!((pct - 100.0).abs() < 0.01, "expected 100.0, got {pct}");
833 }
834
835 #[test]
836 fn test_util_pct_half_busy_is_50() {
837 let pct = util_pct_from_ticks(0, 0, 1600, 800);
839 assert!((pct - 50.0).abs() < 0.01, "expected 50.0, got {pct}");
840 }
841
842 #[test]
843 fn test_util_pct_no_delta_is_zero() {
844 assert_eq!(util_pct_from_ticks(100, 50, 100, 50), 0.0);
846 }
847
848 #[test]
851 fn test_aggregate_util_cores_no_clamp() {
852 let pct = util_pct_from_ticks(0, 0, 1000, 1);
854 let cores = pct / 100.0 * 4.0_f64;
855 assert!(cores > 3.9, "expected close to 4.0, got {cores}");
856 assert!(
857 cores < 4.05,
858 "should not greatly exceed n_cores, got {cores}"
859 );
860 }
861
862 #[test]
865 fn test_util_pct_raw_is_not_clamped() {
866 let raw = util_pct_from_ticks(0, 0, 1000, 0);
868 assert!((raw - 100.0).abs() < 0.01);
869 assert_eq!(raw.clamp(0.0, 100.0), 100.0);
871 }
872
873 #[test]
877 fn test_first_collect_returns_zero_for_delta_fields() {
878 let mut collector = CpuCollector::new(None, false);
879 let metrics = collector.collect().expect("first collect failed");
880 assert_eq!(
881 metrics.utilization_pct, 0.0,
882 "utilization_pct must be 0.0 on first collect, got {}",
883 metrics.utilization_pct
884 );
885 assert!(
886 metrics.per_core_pct.iter().all(|&v| v == 0.0),
887 "per_core_pct must be all-zero on first collect: {:?}",
888 metrics.per_core_pct
889 );
890 assert_eq!(
891 metrics.utime_secs, 0.0,
892 "utime_secs must be 0.0 on first collect, got {}",
893 metrics.utime_secs
894 );
895 assert_eq!(
896 metrics.stime_secs, 0.0,
897 "stime_secs must be 0.0 on first collect, got {}",
898 metrics.stime_secs
899 );
900 }
901
902 #[test]
904 fn test_first_collect_with_pid_returns_some_process_fields() {
905 let pid = i32::try_from(std::process::id()).expect("PID too large");
906 let mut collector = CpuCollector::new(Some(pid), false);
907 let m = collector.collect().expect("collect() failed");
908 assert!(
909 m.process_cores_used.is_some(),
910 "process_cores_used must be Some when PID is tracked"
911 );
912 assert!(
913 m.process_child_count.is_some(),
914 "process_child_count must be Some when PID is tracked"
915 );
916 assert!(
917 m.process_pss_mib.is_some(),
918 "process_pss_mib must be Some when PID is tracked"
919 );
920 assert!(
921 m.process_rss_mib.is_some(),
922 "process_rss_mib must be Some when PID is tracked"
923 );
924 assert!(
925 m.process_utime_secs.is_some(),
926 "process_utime_secs must be Some when PID is tracked"
927 );
928 assert!(
929 m.process_stime_secs.is_some(),
930 "process_stime_secs must be Some when PID is tracked"
931 );
932 assert!(
933 m.process_disk_read_bytes.is_some(),
934 "process_disk_read_bytes must be Some when PID is tracked"
935 );
936 assert!(
937 m.process_disk_write_bytes.is_some(),
938 "process_disk_write_bytes must be Some when PID is tracked"
939 );
940 }
941
942 #[test]
944 fn test_process_tree_memory_nonzero_for_self() {
945 let pid = i32::try_from(std::process::id()).expect("PID too large");
946 let (pss, rss) = process_tree_memory_mib(&[pid]);
947 assert!(
948 pss > 0,
949 "PSS for the current process should be > 0, got {pss}"
950 );
951 assert!(
952 rss > 0,
953 "RSS for the current process should be > 0, got {rss}"
954 );
955 assert!(
956 pss <= rss,
957 "PSS ({pss}) should not exceed RSS ({rss}) for a single process"
958 );
959 }
960
961 #[test]
967 fn test_process_tree_ticks_contains_root_pid() {
968 let ticks = process_tree_ticks(1);
969 assert!(
970 ticks.contains_key(&1),
971 "process_tree_ticks(1) must contain PID 1 (init/systemd is always present)"
972 );
973 }
974
975 #[test]
977 fn test_second_collect_with_pid_nonneg_cores() {
978 let pid = i32::try_from(std::process::id()).expect("PID too large");
979 let mut collector = CpuCollector::new(Some(pid), false);
980 let _ = collector.collect().expect("first collect() failed");
981 let m = collector.collect().expect("second collect() failed");
982 let cores = m
983 .process_cores_used
984 .expect("process_cores_used must be Some");
985 assert!(
986 cores >= 0.0,
987 "process_cores_used must be >= 0.0, got {cores}"
988 );
989 }
990
991 #[test]
993 fn test_second_collect_no_pid_all_process_fields_none() {
994 let mut collector = CpuCollector::new(None, false);
995 let _ = collector.collect().expect("first collect() failed");
996 let m = collector.collect().expect("second collect() failed");
997 assert!(
998 m.process_cores_used.is_none(),
999 "process_cores_used must be None when not tracking"
1000 );
1001 assert!(
1002 m.process_child_count.is_none(),
1003 "process_child_count must be None when not tracking"
1004 );
1005 assert!(
1006 m.process_pss_mib.is_none(),
1007 "process_pss_mib must be None when not tracking"
1008 );
1009 assert!(
1010 m.process_rss_mib.is_none(),
1011 "process_rss_mib must be None when not tracking"
1012 );
1013 assert!(
1014 m.process_utime_secs.is_none(),
1015 "process_utime_secs must be None when not tracking"
1016 );
1017 assert!(
1018 m.process_stime_secs.is_none(),
1019 "process_stime_secs must be None when not tracking"
1020 );
1021 assert!(
1022 m.process_disk_read_bytes.is_none(),
1023 "process_disk_read_bytes must be None when not tracking"
1024 );
1025 assert!(
1026 m.process_disk_write_bytes.is_none(),
1027 "process_disk_write_bytes must be None when not tracking"
1028 );
1029 }
1030
1031 #[test]
1033 fn test_process_count_positive() {
1034 let mut collector = CpuCollector::new(None, false);
1035 let m = collector.collect().expect("collect() failed");
1036 assert!(
1037 m.process_count > 0,
1038 "process_count must be > 0, got {}",
1039 m.process_count
1040 );
1041 }
1042
1043 #[test]
1055 fn test_cutime_correction_cancels_exited_child_ticks() {
1056 let prev: HashMap<i32, (u64, u64)> = [
1057 (200, (50, 0)), (100, (500, 0)), ]
1060 .iter()
1061 .cloned()
1062 .collect();
1063
1064 let curr: HashMap<i32, (u64, u64)> =
1068 [(200, (50 + 250 + 2500, 0))].iter().cloned().collect();
1069
1070 let raw: u64 = curr
1071 .iter()
1072 .map(|(pid, &(cu, cs))| {
1073 let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1074 cu.saturating_sub(pu) + cs.saturating_sub(ps)
1075 })
1076 .sum();
1077 assert_eq!(
1078 raw, 2750,
1079 "raw delta must include the double-counted pre-snapshot child ticks"
1080 );
1081
1082 let exited: u64 = prev
1083 .iter()
1084 .filter(|(pid, _)| !curr.contains_key(pid))
1085 .map(|(_, &(pu, ps))| pu + ps)
1086 .sum();
1087 assert_eq!(
1088 exited, 500,
1089 "exited ticks must equal the child's pre-snapshot tick count"
1090 );
1091
1092 let corrected = raw.saturating_sub(exited);
1093 assert_eq!(
1095 corrected, 2250,
1096 "corrected delta must exclude the child's pre-snapshot ticks"
1097 );
1098 }
1099
1100 #[test]
1107 fn test_cutime_correction_handles_cascaded_exits() {
1108 let prev: HashMap<i32, (u64, u64)> = [
1109 (7, (0, 0)), (8, (100, 0)), (9, (200, 0)), ]
1113 .iter()
1114 .cloned()
1115 .collect();
1116
1117 let curr: HashMap<i32, (u64, u64)> = [(7, (30 + 400, 0))].iter().cloned().collect();
1124
1125 let raw: u64 = curr
1126 .iter()
1127 .map(|(pid, &(cu, cs))| {
1128 let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1129 cu.saturating_sub(pu) + cs.saturating_sub(ps)
1130 })
1131 .sum();
1132 assert_eq!(raw, 430);
1134
1135 let exited: u64 = prev
1136 .iter()
1137 .filter(|(pid, _)| !curr.contains_key(pid))
1138 .map(|(_, &(pu, ps))| pu + ps)
1139 .sum();
1140 assert_eq!(
1141 exited, 300,
1142 "exited = child pre-snap (100) + grandchild pre-snap (200)"
1143 );
1144
1145 let corrected = raw.saturating_sub(exited);
1146 assert_eq!(corrected, 130);
1148 }
1149
1150 #[test]
1168 fn test_process_cores_used_does_not_exceed_system_utilization() {
1169 let pid = i32::try_from(std::process::id()).expect("PID too large");
1170 let mut collector = CpuCollector::new(Some(pid), false);
1171
1172 let mut child = std::process::Command::new("sh")
1175 .args(["-c", "while true; do :; done"])
1176 .spawn()
1177 .expect("failed to spawn sh busy-loop -- required for T-CPU-15");
1178
1179 std::thread::sleep(std::time::Duration::from_millis(200));
1183
1184 let _ = collector.collect().expect("warm-up collect failed");
1186
1187 child.kill().ok();
1192 child.wait().ok();
1193
1194 let m = collector.collect().expect("second collect failed");
1195
1196 let proc_utime = m
1197 .process_utime_secs
1198 .expect("process_utime_secs must be Some");
1199 let proc_stime = m
1200 .process_stime_secs
1201 .expect("process_stime_secs must be Some");
1202 let proc_cpu = proc_utime + proc_stime;
1203 let sys_cpu = m.utime_secs + m.stime_secs;
1204
1205 let tolerance = sys_cpu * 0.15 + 0.05;
1210 assert!(
1211 proc_cpu <= sys_cpu + tolerance,
1212 "process CPU ({proc_cpu:.3}s = {proc_utime:.3}s utime + {proc_stime:.3}s stime) \
1213 must not exceed system CPU ({sys_cpu:.3}s) -- cutime double-counting regression \
1214 for issue #20"
1215 );
1216 }
1217
1218 #[test]
1226 fn test_process_utime_no_double_count_after_child_exits() {
1227 let pid = i32::try_from(std::process::id()).expect("PID too large");
1228 let mut collector = CpuCollector::new(Some(pid), false);
1229
1230 let mut child = std::process::Command::new("sh")
1233 .args(["-c", "for i in $(seq 1 20000); do :; done"])
1234 .spawn()
1235 .expect("failed to spawn sh -- required for T-CPU-16");
1236
1237 std::thread::sleep(std::time::Duration::from_millis(20));
1240
1241 let _ = collector.collect().expect("warm-up collect failed");
1243
1244 let _ = child.wait().expect("failed to wait for child");
1246
1247 let m = collector.collect().expect("second collect failed");
1251
1252 let proc_utime = m
1253 .process_utime_secs
1254 .expect("process_utime_secs must be Some when a PID is tracked");
1255 let sys_utime = m.utime_secs;
1256
1257 let tolerance = sys_utime * 0.05 + 0.05;
1259 assert!(
1260 proc_utime <= sys_utime + tolerance,
1261 "process_utime_secs ({proc_utime:.3}s) exceeds system utime_secs ({sys_utime:.3}s) -- \
1262 cutime double-counting regression (issue #20)"
1263 );
1264 }
1265
1266 #[test]
1283 fn test_cutime_correction_multi_interval_child_exit() {
1284 let pid = i32::try_from(std::process::id()).expect("PID too large");
1285 let mut collector = CpuCollector::new(Some(pid), false);
1286
1287 let mut child = std::process::Command::new("sh")
1289 .args(["-c", "while true; do :; done"])
1290 .spawn()
1291 .expect("failed to spawn sh busy-loop -- required for T-CPU-17");
1292
1293 std::thread::sleep(std::time::Duration::from_millis(100));
1295 let _ = collector.collect().expect("warm-up collect failed");
1296
1297 std::thread::sleep(std::time::Duration::from_millis(100));
1300 let _ = collector.collect().expect("intermediate collect failed");
1301
1302 child.kill().ok();
1306 child.wait().ok();
1307
1308 let m = collector.collect().expect("final collect failed");
1309
1310 let proc_utime = m
1311 .process_utime_secs
1312 .expect("process_utime_secs must be Some");
1313 let proc_stime = m
1314 .process_stime_secs
1315 .expect("process_stime_secs must be Some");
1316 let proc_cpu = proc_utime + proc_stime;
1317 let sys_cpu = m.utime_secs + m.stime_secs;
1318
1319 let tolerance = sys_cpu * 1.0 + 0.50;
1325 assert!(
1326 proc_cpu <= sys_cpu + tolerance,
1327 "process CPU ({proc_cpu:.3}s = {proc_utime:.3}s utime + {proc_stime:.3}s stime) \
1328 must not exceed system CPU ({sys_cpu:.3}s) across multiple intervals -- \
1329 cutime multi-interval regression for issue #20"
1330 );
1331 }
1332
1333 #[test]
1354 fn test_pss_tracks_file_backed_mapping() {
1355 use std::fs;
1356 use std::io::Write as _;
1357 use std::os::unix::io::AsRawFd;
1358
1359 const MAPPING_MIB: usize = 4;
1360 const MAPPING_SIZE: usize = MAPPING_MIB * 1024 * 1024;
1361
1362 let pid = i32::try_from(std::process::id()).expect("PID too large");
1363 let path = format!("/tmp/rt_test_pss_{}", std::process::id());
1364
1365 let (pss_before, rss_before) = process_tree_memory_mib(&[pid]);
1366
1367 {
1369 let mut f = fs::File::create(&path).expect("cannot create temp file for T-CPU-18");
1370 let chunk = vec![0xABu8; 64 * 1024];
1371 for _ in 0..(MAPPING_SIZE / chunk.len()) {
1372 f.write_all(&chunk).expect("write failed");
1373 }
1374 }
1375
1376 let file = fs::File::open(&path).expect("cannot open temp file for T-CPU-18");
1377 let ptr = unsafe {
1378 libc::mmap(
1379 std::ptr::null_mut(),
1380 MAPPING_SIZE,
1381 libc::PROT_READ,
1382 libc::MAP_PRIVATE,
1383 file.as_raw_fd(),
1384 0,
1385 )
1386 };
1387 assert_ne!(ptr, libc::MAP_FAILED, "mmap failed in T-CPU-18");
1388
1389 let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, MAPPING_SIZE) };
1391 let mut checksum = 0u64;
1392 for offset in (0..MAPPING_SIZE).step_by(4096) {
1393 checksum = checksum.wrapping_add(u64::from(slice[offset]));
1394 }
1395 let _ = checksum;
1396
1397 let (pss_after, rss_after) = process_tree_memory_mib(&[pid]);
1398
1399 unsafe { libc::munmap(ptr, MAPPING_SIZE) };
1401 fs::remove_file(&path).ok();
1402
1403 let pss_delta = pss_after.saturating_sub(pss_before);
1404 let rss_delta = rss_after.saturating_sub(rss_before);
1405
1406 const TRUNC_SLACK_MIB: u64 = 1;
1411
1412 assert!(
1413 rss_delta + TRUNC_SLACK_MIB >= MAPPING_MIB as u64,
1414 "RSS must increase by >= {MAPPING_MIB} MiB after touching the mapping: \
1415 before={rss_before} MiB, after={rss_after} MiB (delta={rss_delta} MiB)"
1416 );
1417 assert!(
1418 pss_delta + TRUNC_SLACK_MIB >= MAPPING_MIB as u64,
1419 "PSS must increase by >= {MAPPING_MIB} MiB as sole mapper of the file: \
1420 before={pss_before} MiB, after={pss_after} MiB (delta={pss_delta} MiB)"
1421 );
1422 assert!(
1423 pss_after <= rss_after + TRUNC_SLACK_MIB,
1424 "PSS ({pss_after} MiB) must not exceed RSS ({rss_after} MiB)"
1425 );
1426 let skew = pss_delta.abs_diff(rss_delta);
1430 assert!(
1431 skew <= 1 + TRUNC_SLACK_MIB,
1432 "PSS delta ({pss_delta} MiB) and RSS delta ({rss_delta} MiB) must agree within \
1433 2 MiB for a sole mapper -- larger skew indicates smaps_rollup is not being read"
1434 );
1435 }
1436
1437 #[test]
1445 fn test_mib_truncation_can_underreport_pss_delta() {
1446 let pss_before_bytes: u64 = (8 * 1024 + 1) * 1024; let pss_after_bytes: u64 = (12 * 1024 - 1) * 1024; let before_mib = (pss_before_bytes / 1024) / 1024; let after_mib = (pss_after_bytes / 1024) / 1024; let delta_mib = after_mib.saturating_sub(before_mib); assert_eq!(before_mib, 8);
1455 assert_eq!(after_mib, 11);
1456 assert_eq!(
1457 delta_mib, 3,
1458 "truncation makes ~4 MiB delta appear as 3 MiB"
1459 );
1460
1461 assert!(delta_mib < 4);
1463
1464 const TRUNC_SLACK_MIB: u64 = 1;
1466 assert!(delta_mib + TRUNC_SLACK_MIB >= 4);
1467 }
1468
1469 fn read_pss_kib(pid: i32) -> u64 {
1472 let proc_ = procfs::process::Process::new(pid).expect("process not found");
1473 proc_
1474 .smaps_rollup()
1475 .expect("smaps_rollup unavailable")
1476 .memory_map_rollup
1477 .iter()
1478 .find_map(|m| m.extension.map.get("Pss").copied())
1479 .unwrap_or(0)
1480 / 1024
1481 }
1482
1483 #[test]
1488 fn test_pss_tracks_file_backed_mapping_kib_resolution() {
1489 use std::fs;
1490 use std::io::Write as _;
1491 use std::os::unix::io::AsRawFd;
1492
1493 const MAPPING_MIB: usize = 4;
1494 const MAPPING_SIZE: usize = MAPPING_MIB * 1024 * 1024;
1495 const EXPECTED_DELTA_KIB: u64 = (MAPPING_MIB * 1024) as u64;
1496 const SLACK_KIB: u64 = 64;
1497
1498 let pid = i32::try_from(std::process::id()).expect("PID too large");
1499 let path = format!("/tmp/rt_test_pss_kib_{}", std::process::id());
1500
1501 let pss_before_kib = read_pss_kib(pid);
1502
1503 {
1504 let mut f = fs::File::create(&path).expect("cannot create temp file for T-CPU-18b");
1505 let chunk = vec![0xCDu8; 64 * 1024];
1506 for _ in 0..(MAPPING_SIZE / chunk.len()) {
1507 f.write_all(&chunk).expect("write failed");
1508 }
1509 }
1510
1511 let file = fs::File::open(&path).expect("cannot open temp file for T-CPU-18b");
1512 let ptr = unsafe {
1513 libc::mmap(
1514 std::ptr::null_mut(),
1515 MAPPING_SIZE,
1516 libc::PROT_READ,
1517 libc::MAP_PRIVATE,
1518 file.as_raw_fd(),
1519 0,
1520 )
1521 };
1522 assert_ne!(ptr, libc::MAP_FAILED, "mmap failed in T-CPU-18b");
1523
1524 let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, MAPPING_SIZE) };
1525 let mut checksum = 0u64;
1526 for offset in (0..MAPPING_SIZE).step_by(4096) {
1527 checksum = checksum.wrapping_add(u64::from(slice[offset]));
1528 }
1529 let _ = checksum;
1530
1531 let pss_after_kib = read_pss_kib(pid);
1532
1533 unsafe { libc::munmap(ptr, MAPPING_SIZE) };
1534 fs::remove_file(&path).ok();
1535
1536 let pss_delta_kib = pss_after_kib.saturating_sub(pss_before_kib);
1537
1538 assert!(
1539 pss_delta_kib + SLACK_KIB >= EXPECTED_DELTA_KIB,
1540 "PSS must increase by >= {EXPECTED_DELTA_KIB} KiB (±{SLACK_KIB} KiB) as sole \
1541 mapper: before={pss_before_kib} KiB, after={pss_after_kib} KiB \
1542 (delta={pss_delta_kib} KiB)"
1543 );
1544 }
1545
1546 #[test]
1555 fn test_cutime_correction_skipped_when_exited_exceeds_raw() {
1556 let prev: HashMap<i32, (u64, u64)> =
1557 [(1, (500, 0)), (2, (50000, 0))].iter().cloned().collect();
1558
1559 let curr: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1560
1561 let raw: u64 = curr
1562 .iter()
1563 .map(|(pid, &(cu, cs))| {
1564 let (pu, ps) = prev.get(pid).copied().unwrap_or((cu, cs));
1565 cu.saturating_sub(pu) + cs.saturating_sub(ps)
1566 })
1567 .sum();
1568 assert_eq!(raw, 100, "raw delta is parent's own 100 ticks");
1569
1570 let exited: u64 = prev
1571 .iter()
1572 .filter(|(pid, _)| !curr.contains_key(pid))
1573 .map(|(_, &(pu, ps))| pu + ps)
1574 .sum();
1575 assert_eq!(exited, 50000);
1576
1577 assert_eq!(raw.saturating_sub(exited), 0);
1579
1580 let corrected = if exited <= raw { raw - exited } else { raw };
1582 assert_eq!(
1583 corrected, 100,
1584 "must preserve raw delta when correction is implausible"
1585 );
1586 }
1587
1588 #[test]
1592 fn test_carry_forward_spans_gap_for_reappearing_pid() {
1593 let prev: HashMap<i32, (u64, u64)> =
1594 [(1, (500, 0)), (2, (10000, 0))].iter().cloned().collect();
1595
1596 let mut stored_prev: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1598 for (&pid, &ticks) in &prev {
1599 stored_prev.entry(pid).or_insert(ticks);
1600 }
1601 assert_eq!(
1602 stored_prev.get(&2),
1603 Some(&(10000, 0)),
1604 "child must be carried forward with prev ticks"
1605 );
1606
1607 let curr: HashMap<i32, (u64, u64)> =
1609 [(1, (700, 0)), (2, (11000, 0))].iter().cloned().collect();
1610
1611 let delta_with_cf: u64 = curr
1612 .iter()
1613 .map(|(pid, &(cu, cs))| {
1614 let (pu, ps) = stored_prev.get(pid).copied().unwrap_or((cu, cs));
1615 cu.saturating_sub(pu) + cs.saturating_sub(ps)
1616 })
1617 .sum();
1618 assert_eq!(
1619 delta_with_cf, 1100,
1620 "with carry-forward: parent delta (100) + child delta spanning gap (1000)"
1621 );
1622
1623 let no_cf_prev: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1625 let delta_without_cf: u64 = curr
1626 .iter()
1627 .map(|(pid, &(cu, cs))| {
1628 let (pu, ps) = no_cf_prev.get(pid).copied().unwrap_or((cu, cs));
1629 cu.saturating_sub(pu) + cs.saturating_sub(ps)
1630 })
1631 .sum();
1632 assert_eq!(
1633 delta_without_cf, 100,
1634 "without carry-forward: only parent delta (100), child contribution lost"
1635 );
1636 }
1637
1638 #[test]
1642 fn test_carry_forward_limited_to_one_hop() {
1643 let mut carried_forward: HashSet<i32> = HashSet::new();
1644
1645 let prev_ticks: HashMap<i32, (u64, u64)> =
1647 [(1, (500, 0)), (2, (10000, 0))].iter().cloned().collect();
1648 let mut curr_ticks: HashMap<i32, (u64, u64)> = [(1, (600, 0))].iter().cloned().collect();
1649
1650 let mut new_carried = HashSet::new();
1651 for (&pid, &ticks) in &prev_ticks {
1652 if !curr_ticks.contains_key(&pid) && !carried_forward.contains(&pid) {
1653 curr_ticks.insert(pid, ticks);
1654 new_carried.insert(pid);
1655 }
1656 }
1657 carried_forward = new_carried;
1658
1659 assert!(
1660 curr_ticks.contains_key(&2),
1661 "child must be carried forward in interval N"
1662 );
1663 assert!(
1664 carried_forward.contains(&2),
1665 "child must be in the carried-forward set"
1666 );
1667
1668 let prev_ticks_n1 = curr_ticks.clone();
1670 let mut curr_ticks_n1: HashMap<i32, (u64, u64)> = [(1, (700, 0))].iter().cloned().collect();
1671
1672 let mut new_carried_n1 = HashSet::new();
1673 for (&pid, &ticks) in &prev_ticks_n1 {
1674 if !curr_ticks_n1.contains_key(&pid) && !carried_forward.contains(&pid) {
1675 curr_ticks_n1.insert(pid, ticks);
1676 new_carried_n1.insert(pid);
1677 }
1678 }
1679
1680 assert!(
1681 !curr_ticks_n1.contains_key(&2),
1682 "child must NOT be carried forward a second time"
1683 );
1684 assert!(
1685 !new_carried_n1.contains(&2),
1686 "child must NOT be in the new carried-forward set"
1687 );
1688 }
1689}