1use core::time::Duration;
4
5use crate::history::{ContributorMetric, HistoricalSample};
6use crate::model::{
7 Confidence, MeasuredValue, Measurement, ProcessIdentity, ProcessSnapshot, ProcessState,
8 Severity, SystemSnapshot,
9};
10use crate::units::{Ellipsis, Percent, Rate, format_duration, truncate_tail};
11
12use super::super::{
13 DiagnosticRule, Evidence, Finding, HistoryWindow, Thresholds, TimeWindow, contributor_value,
14};
15use super::{SUSTAINED_CONFIDENCE, as_count, as_percent};
16
17pub const PROCESS_RSS_GROWTH: &str = "process.rss_increasing";
19pub const ZOMBIE_PRESENT: &str = "process.zombie_present";
21pub const PROCESS_CPU_SPIKE: &str = "process.cpu_spike";
23
24const NAME_WIDTH: usize = 24;
26
27const SUMMARY_ZOMBIES: usize = 3;
29
30const SECONDS_PER_MINUTE: f64 = 60.0;
32
33#[derive(Clone, Copy, Debug)]
46pub struct ProcessRssGrowthRule {
47 thresholds: Thresholds,
48}
49
50impl ProcessRssGrowthRule {
51 #[must_use]
53 pub const fn new(thresholds: Thresholds) -> Self {
54 Self { thresholds }
55 }
56}
57
58struct RssSeries {
60 first: f64,
61 last: f64,
62 span: Duration,
63 points: usize,
64 rises: usize,
65 comparisons: usize,
66}
67
68impl RssSeries {
69 fn collect(samples: &[&HistoricalSample], identity: ProcessIdentity) -> Option<Self> {
71 let mut values: Vec<(f64, Duration)> = Vec::new();
72 for sample in samples {
73 if let Some(value) =
74 contributor_value(sample, ContributorMetric::ResidentMemory, identity)
75 {
76 values.push((value, sample.monotonic_offset));
77 }
78 }
79 let (first, first_at) = *values.first()?;
80 let (last, last_at) = *values.last()?;
81 let rises = values
82 .iter()
83 .zip(values.iter().skip(1))
84 .filter(|(earlier, later)| later.0 > earlier.0)
85 .count();
86 Some(Self {
87 first,
88 last,
89 span: last_at.saturating_sub(first_at),
90 points: values.len(),
91 rises,
92 comparisons: values.len().saturating_sub(1),
93 })
94 }
95
96 fn per_minute(&self) -> Option<f64> {
98 let seconds = self.span.as_secs_f64();
99 if seconds <= 0.0 {
100 return None;
101 }
102 let growth = self.last - self.first;
103 (growth > 0.0).then(|| growth / seconds * SECONDS_PER_MINUTE)
104 }
105
106 fn growth_percent(&self) -> Option<Percent> {
108 if self.first <= 0.0 {
109 return None;
110 }
111 #[allow(clippy::cast_possible_truncation)]
114 let percent = ((self.last - self.first) / self.first * 100.0) as f32;
115 Percent::new(percent)
116 }
117}
118
119impl DiagnosticRule for ProcessRssGrowthRule {
120 fn id(&self) -> &'static str {
121 PROCESS_RSS_GROWTH
122 }
123
124 fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
125 let thresholds = &self.thresholds;
126 let minimum = thresholds.minimum_samples();
127 let samples: Vec<&HistoricalSample> = history
128 .recent(thresholds.sustained_window)
129 .filter(|sample| sample.sequence <= current.sequence)
130 .collect();
131 if samples.len() < minimum {
132 return None;
133 }
134
135 let mut worst: Option<(&ProcessSnapshot, RssSeries, f64)> = None;
136 for process in ¤t.processes {
137 let Some(&rss) = process.memory.rss_bytes.fresh() else {
138 continue;
139 };
140 if rss < thresholds.process_rss_minimum_bytes {
141 continue;
142 }
143 let Some(series) = RssSeries::collect(&samples, process.identity) else {
144 continue;
145 };
146 if series.points < minimum {
147 continue;
148 }
149 if series.rises * 2 < series.comparisons {
153 continue;
154 }
155 let Some(per_minute) = series.per_minute() else {
156 continue;
157 };
158 let threshold = thresholds.process_rss_growth_bytes_per_minute as f64;
159 if per_minute < threshold {
160 continue;
161 }
162 if worst
163 .as_ref()
164 .is_none_or(|(_, _, current_rate)| per_minute > *current_rate)
165 {
166 worst = Some((process, series, per_minute));
167 }
168 }
169
170 let (process, series, per_minute) = worst?;
171 let window = TimeWindow::new(series.span, series.points);
172 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
176 let growth_bytes = (series.last - series.first).max(0.0) as u64;
177
178 let mut evidence = vec![
179 Evidence::new(
180 Measurement::new("resident growth", MeasuredValue::Bytes(growth_bytes)),
181 window,
182 ),
183 Evidence::new(
184 Measurement::new(
185 "retained samples",
186 MeasuredValue::Count(as_count(series.points)),
187 ),
188 window,
189 ),
190 Evidence::current(Measurement::new(
191 "pid",
192 MeasuredValue::Count(u64::from(process.identity.pid)),
193 )),
194 ];
195 if let Some(&rss) = process.memory.rss_bytes.fresh() {
196 evidence.push(Evidence::current(Measurement::new(
197 "resident set",
198 MeasuredValue::Bytes(rss),
199 )));
200 }
201 if let Some(rate) = Rate::new(per_minute / SECONDS_PER_MINUTE) {
202 evidence.push(Evidence::new(
203 Measurement::new("growth rate", MeasuredValue::ByteRate(rate)),
204 window,
205 ));
206 }
207 if let Some(share) = process.memory.share_of_total.fresh() {
208 evidence.push(Evidence::current(Measurement::new(
209 "share of total memory",
210 MeasuredValue::Percent(*share),
211 )));
212 }
213
214 let name = truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii);
215 let grown = series
216 .growth_percent()
217 .map_or_else(String::new, |percent| format!(" by {percent}"));
218 let summary = format!(
219 "{name} (pid {}) resident memory grew{grown} over {}, rising in {} of {} retained \
220 samples. This is an observed trend, not a diagnosis of its cause; a cache filling to \
221 its configured bound looks the same.",
222 process.identity.pid,
223 format_duration(series.span),
224 series.rises,
225 series.comparisons,
226 );
227
228 Some(
229 Finding::new(
230 PROCESS_RSS_GROWTH,
231 Severity::Watch,
232 "Process resident memory rising",
233 summary,
234 SUSTAINED_CONFIDENCE,
235 )
236 .with_evidence(evidence),
237 )
238 }
239}
240
241#[derive(Clone, Copy, Debug)]
248pub struct ZombieProcessRule {
249 thresholds: Thresholds,
250}
251
252impl ZombieProcessRule {
253 #[must_use]
255 pub const fn new(thresholds: Thresholds) -> Self {
256 Self { thresholds }
257 }
258}
259
260impl DiagnosticRule for ZombieProcessRule {
261 fn id(&self) -> &'static str {
262 ZOMBIE_PRESENT
263 }
264
265 fn evaluate(&self, current: &SystemSnapshot, _history: &HistoryWindow<'_>) -> Option<Finding> {
266 let zombies: Vec<&ProcessSnapshot> = current
267 .processes
268 .iter()
269 .filter(|process| process.state == ProcessState::Zombie)
270 .collect();
271 if zombies.is_empty() {
272 return None;
273 }
274 let severity = if zombies.len() >= self.thresholds.zombie_watch_count {
275 Severity::Watch
276 } else {
277 Severity::Info
278 };
279
280 let evidence = vec![
281 Evidence::current(Measurement::new(
282 "zombie processes",
283 MeasuredValue::Count(as_count(zombies.len())),
284 )),
285 Evidence::current(Measurement::new(
286 "processes",
287 MeasuredValue::Count(as_count(current.process_count())),
288 )),
289 Evidence::current(Measurement::new(
290 "watch threshold",
291 MeasuredValue::Count(as_count(self.thresholds.zombie_watch_count)),
292 )),
293 ];
294
295 let named: Vec<String> = zombies
296 .iter()
297 .take(SUMMARY_ZOMBIES)
298 .map(|process| {
299 format!(
300 "{} (pid {})",
301 truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii),
302 process.identity.pid
303 )
304 })
305 .collect();
306 let summary = format!(
307 "{} process(es) have exited without being reaped by their parent: {}. A zombie holds \
308 only a process table entry, and signalling one has no effect.",
309 zombies.len(),
310 named.join(", "),
311 );
312
313 Some(
314 Finding::new(
315 ZOMBIE_PRESENT,
316 severity,
317 "Unreaped (zombie) processes present",
318 summary,
319 Confidence::High,
320 )
321 .with_evidence(evidence),
322 )
323 }
324}
325
326#[derive(Clone, Copy, Debug)]
333pub struct ProcessCpuSpikeRule {
334 thresholds: Thresholds,
335}
336
337impl ProcessCpuSpikeRule {
338 #[must_use]
340 pub const fn new(thresholds: Thresholds) -> Self {
341 Self { thresholds }
342 }
343}
344
345impl DiagnosticRule for ProcessCpuSpikeRule {
346 fn id(&self) -> &'static str {
347 PROCESS_CPU_SPIKE
348 }
349
350 fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding> {
351 let thresholds = &self.thresholds;
352 let previous = history.previous_sample(current.sequence)?;
353
354 let mut worst: Option<(&ProcessSnapshot, Percent, f32)> = None;
355 for process in ¤t.processes {
356 let Some(&cpu) = process.cpu.fresh() else {
357 continue;
358 };
359 if cpu.value() < thresholds.process_cpu_spike_percent {
360 continue;
361 }
362 let Some(earlier) =
363 contributor_value(previous, ContributorMetric::Cpu, process.identity)
364 else {
365 continue;
368 };
369 #[allow(clippy::cast_possible_truncation)]
372 let rise = cpu.value() - earlier as f32;
373 if rise < thresholds.process_cpu_spike_points {
374 continue;
375 }
376 if worst
377 .as_ref()
378 .is_none_or(|(_, _, current_rise)| rise > *current_rise)
379 {
380 #[allow(clippy::cast_possible_truncation)]
381 let earlier_percent = as_percent(earlier as f32);
382 worst = Some((process, earlier_percent, rise));
383 }
384 }
385
386 let (process, earlier, rise) = worst?;
387 let span = if current.elapsed.is_zero() {
388 history.expected_interval()
389 } else {
390 current.elapsed
391 };
392 let window = TimeWindow::new(span, 2);
393
394 let mut evidence = vec![
395 Evidence::current(Measurement::new(
396 "pid",
397 MeasuredValue::Count(u64::from(process.identity.pid)),
398 )),
399 Evidence::new(
400 Measurement::new("previous cpu", MeasuredValue::Percent(earlier)),
401 window,
402 ),
403 Evidence::new(
404 Measurement::new("rise", MeasuredValue::Percent(as_percent(rise))),
405 window,
406 ),
407 ];
408 if let Some(&cpu) = process.cpu.fresh() {
409 evidence.push(Evidence::current(Measurement::new(
410 "process cpu",
411 MeasuredValue::Percent(cpu),
412 )));
413 }
414 if let Some(usage) = current.cpu.total.fresh() {
415 evidence.push(Evidence::current(Measurement::new(
416 "cpu busy",
417 MeasuredValue::Percent(usage.busy),
418 )));
419 }
420
421 let name = truncate_tail(&process.name, NAME_WIDTH, Ellipsis::Ascii);
422 let now = process
423 .cpu
424 .fresh()
425 .map_or_else(String::new, |cpu| format!(" to {cpu}"));
426 let summary = format!(
427 "{name} (pid {}) rose {} points{now} between two samples ({}). One sample of \
428 correlation: it is not an explanation of what the system is doing.",
429 process.identity.pid,
430 as_percent(rise),
431 format_duration(span),
432 );
433
434 Some(
435 Finding::new(
436 PROCESS_CPU_SPIKE,
437 Severity::Info,
438 "Process CPU spike",
439 summary,
440 Confidence::Low,
441 )
442 .with_evidence(evidence),
443 )
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::diagnostics::fixtures::{Timeline, add_process, set_cpu, set_memory, snapshot};
451 use crate::model::MetricState;
452
453 const TOTAL: u64 = 32 * 1024 * 1024 * 1024;
454 const MIB: u64 = 1024 * 1024;
455
456 fn growth_rule() -> ProcessRssGrowthRule {
457 ProcessRssGrowthRule::new(Thresholds::default().sanitized())
458 }
459
460 fn zombie_rule() -> ZombieProcessRule {
461 ZombieProcessRule::new(Thresholds::default().sanitized())
462 }
463
464 fn spike_rule() -> ProcessCpuSpikeRule {
465 ProcessCpuSpikeRule::new(Thresholds::default().sanitized())
466 }
467
468 fn timeline() -> Timeline {
469 Timeline::new(Duration::from_secs(1))
470 }
471
472 #[test]
473 fn a_process_with_a_steady_resident_set_produces_no_finding() {
474 let mut timeline = timeline();
475 let current = timeline.push_many(20, |snapshot| {
476 set_memory(snapshot, TOTAL, TOTAL / 2);
477 add_process(
478 snapshot,
479 4_242,
480 "server",
481 Some(5.0),
482 Some(512 * MIB),
483 ProcessState::Running,
484 );
485 });
486 assert!(
487 growth_rule()
488 .evaluate(¤t, &timeline.window())
489 .is_none()
490 );
491 }
492
493 #[test]
494 fn a_steadily_growing_resident_set_is_reported_as_growth_not_as_a_cause() {
495 let mut timeline = timeline();
496 let mut rss = 512 * MIB;
497 let current = timeline.push_many(20, move |snapshot| {
498 rss = rss.saturating_add(64 * MIB);
499 set_memory(snapshot, TOTAL, TOTAL / 2);
500 add_process(
501 snapshot,
502 4_242,
503 "server",
504 Some(5.0),
505 Some(rss),
506 ProcessState::Running,
507 );
508 });
509
510 let finding = growth_rule()
511 .evaluate(¤t, &timeline.window())
512 .expect("64 MiB per second is far above 64 MiB per minute");
513 assert_eq!(finding.rule_id, PROCESS_RSS_GROWTH);
514 assert_eq!(finding.severity, Severity::Watch);
515 assert_eq!(finding.confidence, Confidence::Medium);
516 assert!(
517 finding.summary.contains("server (pid 4242)"),
518 "{}",
519 finding.summary
520 );
521 assert!(
522 finding.summary.contains("not a diagnosis of its cause"),
523 "{}",
524 finding.summary
525 );
526
527 let labels: Vec<&str> = finding
528 .evidence
529 .iter()
530 .map(|item| item.measurement.label)
531 .collect();
532 assert!(labels.contains(&"resident growth"), "{labels:?}");
533 assert!(labels.contains(&"growth rate"), "{labels:?}");
534 assert!(labels.contains(&"retained samples"), "{labels:?}");
535 }
536
537 #[test]
538 fn a_sawtooth_resident_set_is_not_growth() {
539 let mut timeline = timeline();
540 let mut index = 0u64;
541 let current = timeline.push_many(20, move |snapshot| {
542 index += 1;
543 let rss = if index.is_multiple_of(2) {
546 900 * MIB
547 } else {
548 500 * MIB
549 };
550 set_memory(snapshot, TOTAL, TOTAL / 2);
551 add_process(
552 snapshot,
553 4_242,
554 "server",
555 Some(5.0),
556 Some(rss),
557 ProcessState::Running,
558 );
559 });
560 assert!(
561 growth_rule()
562 .evaluate(¤t, &timeline.window())
563 .is_none(),
564 "a process that returns to where it started has not grown"
565 );
566 }
567
568 #[test]
569 fn a_small_process_doubling_is_ignored() {
570 let mut timeline = timeline();
571 let mut rss = MIB;
572 let current = timeline.push_many(20, move |snapshot| {
573 rss = rss.saturating_mul(2).min(64 * MIB);
574 set_memory(snapshot, TOTAL, TOTAL / 2);
575 add_process(
576 snapshot,
577 4_242,
578 "tiny",
579 Some(1.0),
580 Some(rss),
581 ProcessState::Running,
582 );
583 });
584 assert!(
585 growth_rule()
586 .evaluate(¤t, &timeline.window())
587 .is_none(),
588 "below the minimum resident set the noise is not worth reporting"
589 );
590 }
591
592 #[test]
593 fn growth_needs_the_minimum_number_of_retained_samples() {
594 let mut timeline = timeline();
595 let mut rss = 512 * MIB;
596 let current = timeline.push_many(5, move |snapshot| {
597 rss = rss.saturating_add(128 * MIB);
598 set_memory(snapshot, TOTAL, TOTAL / 2);
599 add_process(
600 snapshot,
601 4_242,
602 "server",
603 Some(5.0),
604 Some(rss),
605 ProcessState::Running,
606 );
607 });
608 assert!(
609 growth_rule()
610 .evaluate(¤t, &timeline.window())
611 .is_none()
612 );
613 }
614
615 #[test]
616 fn a_reused_pid_does_not_inherit_the_previous_processes_growth() {
617 let mut timeline = timeline();
621 let mut rss = 512 * MIB;
622 timeline.push_many(15, move |snapshot| {
623 rss = rss.saturating_add(64 * MIB);
624 set_memory(snapshot, TOTAL, TOTAL / 2);
625 add_process(
626 snapshot,
627 4_242,
628 "server",
629 Some(5.0),
630 Some(rss),
631 ProcessState::Running,
632 );
633 });
634 let mut current = timeline.build(|snapshot| {
635 set_memory(snapshot, TOTAL, TOTAL / 2);
636 add_process(
637 snapshot,
638 4_242,
639 "server",
640 Some(5.0),
641 Some(4 * 1024 * MIB),
642 ProcessState::Running,
643 );
644 });
645 if let Some(process) = current.processes.first_mut() {
647 process.identity = ProcessIdentity::new(4_242, 999_999);
648 }
649 assert!(
650 growth_rule()
651 .evaluate(¤t, &timeline.window())
652 .is_none(),
653 "a reused pid must not inherit another process's series"
654 );
655 }
656
657 #[test]
658 fn an_unmeasured_resident_set_is_not_growth() {
659 let mut timeline = timeline();
660 let current = timeline.push_many(20, |snapshot| {
661 set_memory(snapshot, TOTAL, TOTAL / 2);
662 add_process(
663 snapshot,
664 4_242,
665 "server",
666 Some(5.0),
667 None,
668 ProcessState::Running,
669 );
670 });
671 assert!(
672 growth_rule()
673 .evaluate(¤t, &timeline.window())
674 .is_none()
675 );
676 }
677
678 #[test]
679 fn no_zombies_means_no_finding() {
680 let timeline = timeline();
681 let mut current = snapshot();
682 add_process(
683 &mut current,
684 1,
685 "init",
686 Some(0.0),
687 None,
688 ProcessState::Sleeping,
689 );
690 assert!(
691 zombie_rule()
692 .evaluate(¤t, &timeline.window())
693 .is_none()
694 );
695 }
696
697 #[test]
698 fn one_zombie_is_informational_rather_than_a_warning() {
699 let timeline = timeline();
700 let mut current = snapshot();
701 add_process(
702 &mut current,
703 1,
704 "init",
705 Some(0.0),
706 None,
707 ProcessState::Sleeping,
708 );
709 add_process(
710 &mut current,
711 9_182,
712 "node",
713 None,
714 None,
715 ProcessState::Zombie,
716 );
717
718 let finding = zombie_rule()
719 .evaluate(¤t, &timeline.window())
720 .expect("presence is reported");
721 assert_eq!(finding.severity, Severity::Info);
722 assert_eq!(finding.confidence, Confidence::High);
723 assert!(
724 finding.summary.contains("node (pid 9182)"),
725 "{}",
726 finding.summary
727 );
728 assert!(
729 finding.summary.contains("no effect"),
730 "§15.1: signalling a zombie does nothing: {}",
731 finding.summary
732 );
733 }
734
735 #[test]
736 fn a_pile_of_zombies_escalates_to_watch() {
737 let timeline = timeline();
738 let mut current = snapshot();
739 for pid in 100..112 {
740 add_process(
741 &mut current,
742 pid,
743 "worker",
744 None,
745 None,
746 ProcessState::Zombie,
747 );
748 }
749 let finding = zombie_rule()
750 .evaluate(¤t, &timeline.window())
751 .expect("twelve zombies is above the watch threshold");
752 assert_eq!(finding.severity, Severity::Watch);
753 let count = finding
754 .evidence
755 .iter()
756 .find(|item| item.measurement.label == "zombie processes")
757 .expect("the count is evidence");
758 assert_eq!(count.measurement.value, MeasuredValue::Count(12));
759 }
760
761 #[test]
762 fn a_spike_needs_a_previous_sample_to_compare_against() {
763 let mut timeline = timeline();
764 let current = timeline.push(|snapshot| {
765 set_cpu(snapshot, 90.0);
766 add_process(
767 snapshot,
768 31_842,
769 "rustc",
770 Some(287.0),
771 None,
772 ProcessState::Running,
773 );
774 });
775 assert!(
776 spike_rule()
777 .evaluate(¤t, &timeline.window())
778 .is_none(),
779 "§8.2: the first delta sample is warming up, not a spike"
780 );
781 }
782
783 #[test]
784 fn a_sharp_rise_in_one_process_is_reported_with_low_confidence() {
785 let mut timeline = timeline();
786 timeline.push_many(3, |snapshot| {
787 set_cpu(snapshot, 20.0);
788 add_process(
789 snapshot,
790 31_842,
791 "rustc",
792 Some(12.0),
793 None,
794 ProcessState::Running,
795 );
796 });
797 let current = timeline.build(|snapshot| {
798 set_cpu(snapshot, 95.0);
799 add_process(
800 snapshot,
801 31_842,
802 "rustc",
803 Some(287.0),
804 None,
805 ProcessState::Running,
806 );
807 });
808
809 let finding = spike_rule()
810 .evaluate(¤t, &timeline.window())
811 .expect("a 275 point rise is a spike");
812 assert_eq!(finding.severity, Severity::Info);
813 assert_eq!(
814 finding.confidence,
815 Confidence::Low,
816 "§11.3: one sample of evidence is low confidence"
817 );
818 assert!(
819 finding.summary.contains("rustc (pid 31842)"),
820 "{}",
821 finding.summary
822 );
823 assert!(
824 finding.summary.contains("not an explanation"),
825 "§2.2 forbids claiming causation: {}",
826 finding.summary
827 );
828
829 let labels: Vec<&str> = finding
830 .evidence
831 .iter()
832 .map(|item| item.measurement.label)
833 .collect();
834 assert!(labels.contains(&"previous cpu"), "{labels:?}");
835 assert!(labels.contains(&"rise"), "{labels:?}");
836 }
837
838 #[test]
839 fn a_process_below_the_spike_floor_is_ignored_however_fast_it_rose() {
840 let mut timeline = timeline();
841 timeline.push_many(3, |snapshot| {
842 add_process(
843 snapshot,
844 31_842,
845 "rustc",
846 Some(1.0),
847 None,
848 ProcessState::Running,
849 );
850 });
851 let current = timeline.build(|snapshot| {
852 add_process(
853 snapshot,
854 31_842,
855 "rustc",
856 Some(80.0),
857 None,
858 ProcessState::Running,
859 );
860 });
861 assert!(
862 spike_rule()
863 .evaluate(¤t, &timeline.window())
864 .is_none(),
865 "80% of one core is not a spike worth reporting"
866 );
867 }
868
869 #[test]
870 fn a_new_process_at_high_cpu_is_not_a_spike() {
871 let mut timeline = timeline();
872 timeline.push_many(3, |snapshot| {
873 add_process(snapshot, 1, "init", Some(0.0), None, ProcessState::Sleeping);
874 });
875 let current = timeline.build(|snapshot| {
876 add_process(snapshot, 1, "init", Some(0.0), None, ProcessState::Sleeping);
877 add_process(
878 snapshot,
879 31_842,
880 "rustc",
881 Some(287.0),
882 None,
883 ProcessState::Running,
884 );
885 });
886 assert!(
887 spike_rule()
888 .evaluate(¤t, &timeline.window())
889 .is_none(),
890 "a process with no previous retained value has no delta (§8.2)"
891 );
892 }
893
894 #[test]
895 fn an_unmeasured_process_cpu_is_not_a_spike() {
896 let mut timeline = timeline();
897 timeline.push_many(3, |snapshot| {
898 add_process(
899 snapshot,
900 31_842,
901 "rustc",
902 Some(12.0),
903 None,
904 ProcessState::Running,
905 );
906 });
907 let mut current = timeline.build(|snapshot| {
908 add_process(
909 snapshot,
910 31_842,
911 "rustc",
912 Some(287.0),
913 None,
914 ProcessState::Running,
915 );
916 });
917 if let Some(process) = current.processes.first_mut() {
918 process.cpu = MetricState::PermissionDenied;
919 }
920 assert!(
921 spike_rule()
922 .evaluate(¤t, &timeline.window())
923 .is_none()
924 );
925 }
926
927 #[test]
928 fn the_largest_rise_is_the_one_reported() {
929 let mut timeline = timeline();
930 timeline.push_many(3, |snapshot| {
931 add_process(
932 snapshot,
933 1_221,
934 "postgres",
935 Some(10.0),
936 None,
937 ProcessState::Running,
938 );
939 add_process(
940 snapshot,
941 31_842,
942 "rustc",
943 Some(10.0),
944 None,
945 ProcessState::Running,
946 );
947 });
948 let current = timeline.build(|snapshot| {
949 add_process(
950 snapshot,
951 1_221,
952 "postgres",
953 Some(120.0),
954 None,
955 ProcessState::Running,
956 );
957 add_process(
958 snapshot,
959 31_842,
960 "rustc",
961 Some(287.0),
962 None,
963 ProcessState::Running,
964 );
965 });
966 let finding = spike_rule()
967 .evaluate(¤t, &timeline.window())
968 .expect("both rose; the larger rise wins");
969 assert!(finding.summary.contains("rustc"), "{}", finding.summary);
970 }
971}