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