1use crate::core::{
26 apply_filters, flatten_targets, resolve_targets_excluding_self, NameMatch, Process, Resolved,
27};
28use crate::error::{ExitCode, ProcError, Result};
29use crate::ui::{format_duration, must_confirm, plural, prompt, Printer};
30use crate::{out_print, out_println};
31use clap::Args;
32use colored::*;
33use serde::Serialize;
34use std::time::Duration;
35
36#[cfg(unix)]
37use nix::sys::signal::Signal;
38
39#[derive(Args, Debug)]
41pub struct UnstickCommand {
42 #[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
44 target: Vec<String>,
45
46 #[arg(long, short, default_value = "300")]
48 timeout: u64,
49
50 #[arg(long, short = 'f')]
52 force: bool,
53
54 #[arg(long, short = 'y')]
56 yes: bool,
57
58 #[arg(long)]
60 dry_run: bool,
61
62 #[arg(long, short = 'v')]
64 verbose: bool,
65
66 #[arg(long, short = 'j')]
68 json: bool,
69
70 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
73 pub in_dir: Option<String>,
74
75 #[arg(long = "by", short = 'b')]
77 pub by_name: Option<String>,
78
79 #[arg(long)]
81 pub cmd: bool,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85enum Outcome {
86 Recovered, Terminated, StillStuck, NotStuck, Failed(String),
91}
92
93impl UnstickCommand {
94 pub fn execute(&self) -> Result<()> {
96 let printer = Printer::from_flags(self.json, self.verbose);
97
98 let (mut stuck, not_found) = if !self.target.is_empty() {
100 let resolved = self.resolve_target_processes(&self.target)?;
102 if let Some(warning) = resolved.warning() {
103 printer.warning(&warning);
104 }
105 (resolved.processes, resolved.not_found)
106 } else {
107 let timeout = Duration::from_secs(self.timeout);
109 (Process::find_stuck(timeout)?, Vec::new())
110 };
111
112 apply_filters(
114 &mut stuck,
115 &self.in_dir,
116 &self.by_name,
117 NameMatch::from_flag(self.cmd),
118 )?;
119
120 if stuck.is_empty() {
121 if !self.target.is_empty() {
123 return Err(ProcError::ProcessNotFound(self.target.join(" ")));
124 }
125 if self.json {
126 printer.print_json(&UnstickOutput {
127 action: "unstick",
128 success: true,
129 dry_run: self.dry_run,
130 force: self.force,
131 found: 0,
132 recovered: 0,
133 not_stuck: 0,
134 still_stuck: 0,
135 terminated: 0,
136 failed: 0,
137 not_found: ¬_found,
138 processes: Vec::new(),
139 });
140 } else {
141 printer.success("No stuck processes found");
142 }
143 return Ok(());
144 }
145
146 if self.dry_run || !self.yes {
149 Process::measure_cpu(&mut stuck);
150 }
151
152 let asked_below = !self.dry_run
155 && matches!(
156 must_confirm("unstick", self.yes, self.json, &stuck, Some(¬_found)),
157 Ok(true)
158 );
159 if !self.json && !asked_below {
160 self.show_processes(&stuck);
161 }
162
163 if self.dry_run {
165 if self.json {
166 printer.print_json(&UnstickOutput {
167 action: "unstick",
168 success: true,
169 dry_run: true,
170 force: self.force,
171 found: stuck.len(),
172 recovered: 0,
173 not_stuck: 0,
174 still_stuck: 0,
175 terminated: 0,
176 failed: 0,
177 not_found: ¬_found,
178 processes: stuck
179 .iter()
180 .map(|p| ProcessOutcome {
181 pid: p.pid,
182 name: p.name.clone(),
183 uptime_secs: p.uptime_secs,
184 outcome: "would_attempt".to_string(),
185 })
186 .collect(),
187 });
188 } else {
189 out_println!(
190 "\n{} Dry run: Would attempt to unstick {} process{}",
191 "ℹ".blue().bold(),
192 stuck.len().to_string().cyan().bold(),
193 plural(stuck.len())
194 );
195 if self.force {
196 out_println!(" With --force: will terminate if recovery fails");
197 } else {
198 out_println!(" Without --force: will only attempt recovery");
199 }
200 out_println!();
201 }
202 return Ok(());
203 }
204
205 if must_confirm("unstick", self.yes, self.json, &stuck, Some(¬_found))? {
207 let question = format!("Unstick {} process{}?", stuck.len(), plural(stuck.len()));
208
209 let show = || {
210 self.show_processes(&stuck);
211 if self.force {
212 out_println!(
213 "\n{} With --force: processes will be terminated if recovery fails.\n",
214 "!".yellow().bold()
215 );
216 } else {
217 out_println!(
218 "\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
219 "ℹ".blue().bold()
220 );
221 }
222 };
223 if !prompt::ask("unstick", &stuck, Some(¬_found), &question, show)? {
224 printer.warning("Aborted");
225 return Ok(());
226 }
227 }
228
229 let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
231
232 for proc in &stuck {
233 if !self.json {
234 out_print!(
235 " {} {} [PID {}]... ",
236 "→".bright_black(),
237 proc.name.white(),
238 proc.pid.to_string().cyan()
239 );
240 }
241
242 let outcome = self.attempt_unstick(proc);
243
244 if !self.json {
245 out_println!("{}", outcome_text(&outcome));
246 }
247
248 outcomes.push((proc.clone(), outcome));
249 }
250
251 let recovered = outcomes
253 .iter()
254 .filter(|(_, o)| *o == Outcome::Recovered)
255 .count();
256 let terminated = outcomes
257 .iter()
258 .filter(|(_, o)| *o == Outcome::Terminated)
259 .count();
260 let still_stuck = outcomes
261 .iter()
262 .filter(|(_, o)| *o == Outcome::StillStuck)
263 .count();
264 let not_stuck = outcomes
265 .iter()
266 .filter(|(_, o)| *o == Outcome::NotStuck)
267 .count();
268 let failed = outcomes
269 .iter()
270 .filter(|(_, o)| matches!(o, Outcome::Failed(_)))
271 .count();
272
273 if self.json {
275 printer.print_json(&UnstickOutput {
276 action: "unstick",
277 success: failed == 0 && still_stuck == 0,
278 dry_run: false,
279 force: self.force,
280 found: stuck.len(),
281 recovered,
282 not_stuck,
283 still_stuck,
284 terminated,
285 failed,
286 not_found: ¬_found,
287 processes: outcomes
288 .iter()
289 .map(|(p, o)| ProcessOutcome {
290 pid: p.pid,
291 name: p.name.clone(),
292 uptime_secs: p.uptime_secs,
293 outcome: outcome_label(o),
294 })
295 .collect(),
296 });
297 } else {
298 out_println!();
299 for line in summary_lines(recovered, not_stuck, terminated, still_stuck, failed) {
300 out_println!("{line}");
301 }
302 }
303
304 if failed > 0 || still_stuck > 0 {
305 return Err(ProcError::reported(
306 ExitCode::GeneralError,
307 format!(
308 "{} process{} still stuck, {} failed",
309 still_stuck,
310 plural(still_stuck),
311 failed
312 ),
313 ));
314 }
315
316 Ok(())
317 }
318
319 fn resolve_target_processes(&self, typed: &[String]) -> Result<Resolved> {
321 let targets = flatten_targets(typed);
322 let resolved = resolve_targets_excluding_self(&targets, NameMatch::from_flag(self.cmd));
323 if resolved.processes.is_empty() {
324 Err(ProcError::ProcessNotFound(typed.join(" ")))
325 } else {
326 Ok(resolved)
327 }
328 }
329
330 fn is_stuck(&self, proc: &Process) -> bool {
334 proc.looks_stuck_now()
335 }
336
337 fn attempt_unstick(&self, proc: &Process) -> Outcome {
339 if !self.target.is_empty() && !self.is_stuck(proc) {
341 return Outcome::NotStuck;
342 }
343
344 #[cfg(unix)]
345 return recover(proc, self.force);
346 #[cfg(not(unix))]
348 return recover_by_termination(proc, self.force);
349 }
350
351 fn show_processes(&self, processes: &[Process]) {
352 let label = if !self.target.is_empty() {
353 "Target"
354 } else {
355 "Found stuck"
356 };
357
358 out_println!(
359 "\n{} {} {} process{}:\n",
360 "!".yellow().bold(),
361 label,
362 processes.len().to_string().cyan().bold(),
363 plural(processes.len())
364 );
365
366 for proc in processes {
367 let uptime = proc
368 .start_time
369 .map(|st| {
370 let now = std::time::SystemTime::now()
371 .duration_since(std::time::UNIX_EPOCH)
372 .map(|d| d.as_secs().saturating_sub(st))
373 .unwrap_or(0);
374 format_duration(now)
375 })
376 .unwrap_or_else(|| "unknown".to_string());
377
378 out_println!(
379 " {} {} [PID {}] - {:.1}% CPU, running for {}",
380 "→".bright_black(),
381 proc.name.white().bold(),
382 proc.pid.to_string().cyan(),
383 proc.cpu_percent,
384 uptime.yellow()
385 );
386 }
387 }
388}
389
390trait Steps {
393 fn is_running(&self) -> bool;
394 fn pause(&self, secs: u64);
395 fn terminate(&self) -> Result<()>;
396 fn kill(&self) -> Result<()>;
397 #[cfg(unix)]
399 fn wake(&self);
400 #[cfg(unix)]
402 fn interrupt(&self) -> Result<()>;
403 #[cfg(unix)]
406 fn calmed_down(&self) -> bool;
407}
408
409impl Steps for Process {
410 fn is_running(&self) -> bool {
411 Process::is_running(self)
412 }
413
414 fn pause(&self, secs: u64) {
415 std::thread::sleep(Duration::from_secs(secs));
416 }
417
418 fn terminate(&self) -> Result<()> {
419 Process::terminate(self)
420 }
421
422 fn kill(&self) -> Result<()> {
423 Process::kill(self)
424 }
425
426 #[cfg(unix)]
428 fn wake(&self) {
429 let _ = self.send_signal(Signal::SIGCONT);
430 }
431
432 #[cfg(unix)]
433 fn interrupt(&self) -> Result<()> {
434 self.send_signal(Signal::SIGINT)
435 }
436
437 #[cfg(unix)]
438 fn calmed_down(&self) -> bool {
439 self.cpu_over(Duration::from_secs(1))
440 .is_some_and(|cpu| cpu < 10.0)
441 }
442}
443
444fn after_kill(killed: Result<()>, still_running: bool) -> Outcome {
447 match killed {
448 Ok(()) => Outcome::Terminated,
449 Err(_) if !still_running => Outcome::Terminated,
450 Err(e) => Outcome::Failed(e.to_string()),
451 }
452}
453
454#[cfg(unix)]
456fn recover(steps: &impl Steps, force: bool) -> Outcome {
457 steps.wake();
459 if steps.calmed_down() {
460 return Outcome::Recovered;
461 }
462
463 if steps.interrupt().is_err() && !steps.is_running() {
465 return Outcome::Terminated;
466 }
467 steps.pause(3);
468
469 if !steps.is_running() {
470 return Outcome::Terminated;
471 }
472 if steps.calmed_down() {
473 return Outcome::Recovered;
474 }
475
476 if !force {
478 return Outcome::StillStuck;
479 }
480
481 if steps.terminate().is_err() && !steps.is_running() {
483 return Outcome::Terminated;
484 }
485 steps.pause(5);
486
487 if !steps.is_running() {
488 return Outcome::Terminated;
489 }
490
491 after_kill(steps.kill(), steps.is_running())
493}
494
495#[cfg(any(not(unix), test))]
498fn recover_by_termination(steps: &impl Steps, force: bool) -> Outcome {
499 if !force {
500 return Outcome::StillStuck;
501 }
502
503 if steps.terminate().is_ok() {
504 steps.pause(3);
505 if !steps.is_running() {
506 return Outcome::Terminated;
507 }
508 }
509
510 after_kill(steps.kill(), steps.is_running())
511}
512
513fn outcome_text(outcome: &Outcome) -> String {
515 match outcome {
516 Outcome::Recovered => "recovered".green().to_string(),
517 Outcome::Terminated => "terminated".yellow().to_string(),
518 Outcome::StillStuck => "still stuck".red().to_string(),
519 Outcome::NotStuck => "not stuck".blue().to_string(),
520 Outcome::Failed(e) => format!("{}: {}", "failed".red(), e),
521 }
522}
523
524fn outcome_label(outcome: &Outcome) -> String {
526 match outcome {
527 Outcome::Recovered => "recovered".to_string(),
528 Outcome::Terminated => "terminated".to_string(),
529 Outcome::StillStuck => "still_stuck".to_string(),
530 Outcome::NotStuck => "not_stuck".to_string(),
531 Outcome::Failed(e) => format!("failed: {}", e),
532 }
533}
534
535fn summary_lines(
537 recovered: usize,
538 not_stuck: usize,
539 terminated: usize,
540 still_stuck: usize,
541 failed: usize,
542) -> Vec<String> {
543 let mut lines = Vec::new();
544 if recovered > 0 {
545 lines.push(format!(
546 "{} {} process{} recovered",
547 "✓".green().bold(),
548 recovered.to_string().cyan().bold(),
549 plural(recovered)
550 ));
551 }
552 if not_stuck > 0 {
553 lines.push(format!(
554 "{} {} process{} not stuck",
555 "ℹ".blue().bold(),
556 not_stuck.to_string().cyan().bold(),
557 if not_stuck == 1 { " was" } else { "es were" }
558 ));
559 }
560 if terminated > 0 {
561 lines.push(format!(
562 "{} {} process{} terminated",
563 "!".yellow().bold(),
564 terminated.to_string().cyan().bold(),
565 plural(terminated)
566 ));
567 }
568 if still_stuck > 0 {
569 lines.push(format!(
570 "{} {} process{} still stuck (use --force to terminate)",
571 "✗".red().bold(),
572 still_stuck.to_string().cyan().bold(),
573 plural(still_stuck)
574 ));
575 }
576 if failed > 0 {
577 lines.push(format!(
578 "{} {} process{} failed",
579 "✗".red().bold(),
580 failed.to_string().cyan().bold(),
581 plural(failed)
582 ));
583 }
584 lines
585}
586
587#[derive(Serialize)]
588struct UnstickOutput<'a> {
589 action: &'static str,
590 success: bool,
591 dry_run: bool,
592 force: bool,
593 found: usize,
594 recovered: usize,
595 not_stuck: usize,
596 still_stuck: usize,
597 terminated: usize,
598 failed: usize,
599 not_found: &'a [String],
601 processes: Vec<ProcessOutcome>,
602}
603
604#[derive(Serialize)]
605struct ProcessOutcome {
606 pid: u32,
607 name: String,
608 #[serde(skip_serializing_if = "Option::is_none")]
609 uptime_secs: Option<u64>,
610 outcome: String,
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use std::cell::RefCell;
617 use std::collections::VecDeque;
618
619 #[derive(Default)]
621 struct Fake {
622 running: RefCell<VecDeque<bool>>,
624 #[cfg(unix)]
626 calm: RefCell<VecDeque<bool>>,
627 #[cfg(unix)]
628 interrupt_fails: bool,
629 terminate_fails: bool,
630 kill_fails: bool,
631 calls: RefCell<Vec<String>>,
632 }
633
634 impl Fake {
635 fn running(answers: &[bool]) -> Self {
636 Fake {
637 running: RefCell::new(answers.iter().copied().collect()),
638 ..Fake::default()
639 }
640 }
641
642 fn calls(&self) -> Vec<String> {
643 self.calls.borrow().clone()
644 }
645
646 fn note(&self, what: &str) {
647 self.calls.borrow_mut().push(what.to_string());
648 }
649
650 fn result(&self, fails: bool) -> Result<()> {
651 if fails {
652 Err(ProcError::SignalError("denied".to_string()))
653 } else {
654 Ok(())
655 }
656 }
657 }
658
659 impl Steps for Fake {
660 fn is_running(&self) -> bool {
661 let mut answers = self.running.borrow_mut();
662 if answers.len() > 1 {
663 answers.pop_front().unwrap()
664 } else {
665 answers.front().copied().unwrap_or(true)
666 }
667 }
668
669 fn pause(&self, secs: u64) {
670 self.note(&format!("pause {secs}"));
671 }
672
673 fn terminate(&self) -> Result<()> {
674 self.note("terminate");
675 self.result(self.terminate_fails)
676 }
677
678 fn kill(&self) -> Result<()> {
679 self.note("kill");
680 self.result(self.kill_fails)
681 }
682
683 #[cfg(unix)]
684 fn wake(&self) {
685 self.note("wake");
686 }
687
688 #[cfg(unix)]
689 fn interrupt(&self) -> Result<()> {
690 self.note("interrupt");
691 self.result(self.interrupt_fails)
692 }
693
694 #[cfg(unix)]
695 fn calmed_down(&self) -> bool {
696 self.note("calmed_down");
697 self.calm.borrow_mut().pop_front().unwrap_or(false)
698 }
699 }
700
701 fn plain(text: &str) -> String {
703 let mut out = String::new();
704 let mut chars = text.chars();
705 while let Some(c) = chars.next() {
706 if c == '\x1b' {
707 for c in chars.by_ref() {
708 if c == 'm' {
709 break;
710 }
711 }
712 } else {
713 out.push(c);
714 }
715 }
716 out
717 }
718
719 #[test]
720 fn colour_codes_are_stripped_from_text() {
721 assert_eq!(plain("\x1b[1;31mred\x1b[0m and plain"), "red and plain");
722 }
723
724 #[cfg(unix)]
725 #[test]
726 fn a_process_that_calms_after_sigcont_is_recovered_without_a_signal() {
727 let fake = Fake {
728 calm: RefCell::new([true].into()),
729 ..Fake::running(&[true])
730 };
731 assert_eq!(recover(&fake, true), Outcome::Recovered);
732 assert_eq!(fake.calls(), ["wake", "calmed_down"]);
733 }
734
735 #[cfg(unix)]
736 #[test]
737 fn a_process_gone_after_a_failed_sigint_is_terminated() {
738 let fake = Fake {
739 interrupt_fails: true,
740 ..Fake::running(&[false])
741 };
742 assert_eq!(recover(&fake, false), Outcome::Terminated);
743 assert_eq!(fake.calls(), ["wake", "calmed_down", "interrupt"]);
744 }
745
746 #[cfg(unix)]
747 #[test]
748 fn a_process_that_exits_after_sigint_is_terminated() {
749 let fake = Fake::running(&[false]);
750 assert_eq!(recover(&fake, false), Outcome::Terminated);
751 assert_eq!(
752 fake.calls(),
753 ["wake", "calmed_down", "interrupt", "pause 3"]
754 );
755 }
756
757 #[cfg(unix)]
758 #[test]
759 fn a_process_that_calms_after_sigint_is_recovered() {
760 let fake = Fake {
761 calm: RefCell::new([false, true].into()),
762 ..Fake::running(&[true])
763 };
764 assert_eq!(recover(&fake, false), Outcome::Recovered);
765 }
766
767 #[cfg(unix)]
768 #[test]
769 fn without_force_a_process_that_stays_busy_is_still_stuck() {
770 let fake = Fake::running(&[true]);
771 assert_eq!(recover(&fake, false), Outcome::StillStuck);
772 assert!(!fake.calls().contains(&"terminate".to_string()));
773 }
774
775 #[cfg(unix)]
776 #[test]
777 fn force_escalates_from_sigterm_to_sigkill() {
778 let fake = Fake::running(&[true]);
779 assert_eq!(recover(&fake, true), Outcome::Terminated);
780 assert_eq!(&fake.calls()[5..], ["terminate", "pause 5", "kill"]);
781 }
782
783 #[cfg(unix)]
784 #[test]
785 fn force_stops_at_sigterm_when_that_ends_the_process() {
786 let fake = Fake::running(&[true, false]);
788 assert_eq!(recover(&fake, true), Outcome::Terminated);
789 assert!(!fake.calls().contains(&"kill".to_string()));
790 }
791
792 #[cfg(unix)]
793 #[test]
794 fn force_with_a_failed_sigterm_on_a_gone_process_is_terminated() {
795 let fake = Fake {
796 terminate_fails: true,
797 ..Fake::running(&[true, false])
798 };
799 assert_eq!(recover(&fake, true), Outcome::Terminated);
800 assert!(!fake.calls().contains(&"pause 5".to_string()));
801 }
802
803 #[cfg(unix)]
804 #[test]
805 fn a_kill_that_fails_on_a_process_still_running_is_failed() {
806 let fake = Fake {
807 kill_fails: true,
808 ..Fake::running(&[true])
809 };
810 assert_eq!(
811 recover(&fake, true),
812 Outcome::Failed("Signal failed: denied".to_string())
813 );
814 }
815
816 #[test]
817 fn a_kill_that_fails_on_a_process_already_gone_is_terminated() {
818 assert_eq!(
819 after_kill(Err(ProcError::ProcessNotFound("1".into())), false),
820 Outcome::Terminated
821 );
822 assert_eq!(after_kill(Ok(()), true), Outcome::Terminated);
823 }
824
825 #[test]
826 fn termination_only_recovery_does_nothing_without_force() {
827 let fake = Fake::running(&[true]);
828 assert_eq!(recover_by_termination(&fake, false), Outcome::StillStuck);
829 assert!(fake.calls().is_empty());
830 }
831
832 #[test]
833 fn termination_only_recovery_with_force_stops_when_the_request_works() {
834 let fake = Fake::running(&[false]);
835 assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
836 assert_eq!(fake.calls(), ["terminate", "pause 3"]);
837 }
838
839 #[test]
840 fn termination_only_recovery_kills_what_ignores_the_request() {
841 let fake = Fake::running(&[true]);
842 assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
843 assert_eq!(fake.calls(), ["terminate", "pause 3", "kill"]);
844
845 let fake = Fake {
846 terminate_fails: true,
847 ..Fake::running(&[true])
848 };
849 assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
850 assert_eq!(fake.calls(), ["terminate", "kill"]);
851
852 let fake = Fake {
853 kill_fails: true,
854 ..Fake::running(&[true])
855 };
856 assert_eq!(
857 recover_by_termination(&fake, true),
858 Outcome::Failed("Signal failed: denied".to_string())
859 );
860 }
861
862 #[test]
863 fn a_process_that_outlasts_the_request_is_killed_and_seen_gone() {
864 let fake = Fake::running(&[true, false]);
866 assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
867 assert_eq!(fake.calls(), ["terminate", "pause 3", "kill"]);
868 }
869
870 #[test]
871 fn the_steps_act_on_the_real_process() {
872 let mut child = crate::test_support::idle_child();
873 let record = crate::test_support::record_of(&child);
874 assert!(Steps::is_running(&record));
875 Steps::pause(&record, 0);
877 Steps::kill(&record).unwrap();
878 let _ = child.wait();
879 let gone = (0..200).any(|_| {
882 std::thread::sleep(Duration::from_millis(50));
883 !Steps::is_running(&record)
884 });
885 assert!(gone, "still listed 10 seconds after it exited");
886 crate::test_support::end(&mut child);
887 }
888
889 #[test]
890 fn each_outcome_has_its_words_in_text_and_json() {
891 let cases = [
892 (Outcome::Recovered, "recovered", "recovered"),
893 (Outcome::Terminated, "terminated", "terminated"),
894 (Outcome::StillStuck, "still stuck", "still_stuck"),
895 (Outcome::NotStuck, "not stuck", "not_stuck"),
896 (
897 Outcome::Failed("denied".to_string()),
898 "failed: denied",
899 "failed: denied",
900 ),
901 ];
902 for (outcome, text, label) in cases {
903 assert_eq!(plain(&outcome_text(&outcome)), text);
904 assert_eq!(outcome_label(&outcome), label);
905 }
906 }
907
908 #[test]
909 fn the_summary_has_a_line_for_each_kind_that_occurred() {
910 let all: Vec<String> = summary_lines(2, 1, 3, 1, 2)
911 .iter()
912 .map(|l| plain(l))
913 .collect();
914 assert_eq!(
915 all,
916 [
917 "✓ 2 processes recovered",
918 "ℹ 1 process was not stuck",
919 "! 3 processes terminated",
920 "✗ 1 process still stuck (use --force to terminate)",
921 "✗ 2 processes failed",
922 ]
923 );
924 let some: Vec<String> = summary_lines(0, 2, 0, 0, 1)
925 .iter()
926 .map(|l| plain(l))
927 .collect();
928 assert_eq!(some, ["ℹ 2 processes were not stuck", "✗ 1 process failed"]);
929 assert!(summary_lines(0, 0, 0, 0, 0).is_empty());
930 }
931}