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