Skip to main content

proc_cli/commands/
unstick.rs

1//! Unstick command - Attempt to recover stuck processes
2//!
3//! Tries gentle recovery signals first. SIGINT can end a process that does not
4//! handle it, with or without --force; SIGTERM and SIGKILL are sent only with
5//! --force.
6//!
7//! Recovery sequence:
8//! 1. SIGCONT (wake if stopped)
9//! 2. SIGINT (interrupt, like Ctrl+C)
10//!
11//! With --force:
12//! 3. SIGTERM (polite termination request)
13//! 4. SIGKILL (force, last resort)
14//!
15//! Usage:
16//!   proc unstick           # Find and unstick all stuck processes
17//!   proc unstick :3000     # Unstick process on port 3000
18//!   proc unstick 1234      # Unstick PID 1234
19//!   proc unstick node      # Unstick the stuck processes named node
20//!   proc unstick node --cmd   # ... or with node anywhere in their command line
21//!   proc unstick :3000 1234   # Several targets: separate arguments
22//!
23//! Commas still work (`proc unstick :3000,1234`) but are deprecated.
24
25use 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/// Attempt to recover stuck processes
40#[derive(Args, Debug)]
41pub struct UnstickCommand {
42    /// Target(s): PID, :port, or name (separate arguments for several; omit to find all stuck)
43    #[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
44    target: Vec<String>,
45
46    /// Without a target: only stuck processes running longer than this many seconds
47    #[arg(long, short, default_value = "300")]
48    timeout: u64,
49
50    /// Force termination if recovery fails
51    #[arg(long, short = 'f')]
52    force: bool,
53
54    /// Skip confirmation prompt
55    #[arg(long, short = 'y')]
56    yes: bool,
57
58    /// Show what would be done without doing it
59    #[arg(long)]
60    dry_run: bool,
61
62    /// Show verbose output
63    #[arg(long, short = 'v')]
64    verbose: bool,
65
66    /// Output as JSON
67    #[arg(long, short = 'j')]
68    json: bool,
69
70    /// Filter by directory; takes the next word as the directory unless it starts with '-'
71    /// (bare --in is the current directory, --in=DIR is explicit)
72    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
73    pub in_dir: Option<String>,
74
75    /// Filter by process name
76    #[arg(long = "by", short = 'b')]
77    pub by_name: Option<String>,
78
79    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
80    #[arg(long)]
81    pub cmd: bool,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85enum Outcome {
86    Recovered,  // Process unstuck and still running
87    Terminated, // Gone: killed with --force, or ended by the SIGINT step
88    StillStuck, // Could not recover, not terminated (no --force)
89    NotStuck,   // Process wasn't stuck to begin with
90    Failed(String),
91}
92
93impl UnstickCommand {
94    /// Executes the unstick command, attempting to recover hung processes.
95    pub fn execute(&self) -> Result<()> {
96        let printer = Printer::from_flags(self.json, self.verbose);
97
98        // Get processes to unstick, and the targets that matched nothing
99        let (mut stuck, not_found) = if !self.target.is_empty() {
100            // Specific targets
101            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            // Auto-discover stuck processes
108            let timeout = Duration::from_secs(self.timeout);
109            (Process::find_stuck(timeout)?, Vec::new())
110        };
111
112        // Apply --in and --by filters
113        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            // An explicit target filtered down to nothing is "not found", like other commands
122            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: &not_found,
138                    processes: Vec::new(),
139                });
140            } else {
141                printer.success("No stuck processes found");
142            }
143            return Ok(());
144        }
145
146        // Real CPU readings whenever the list is shown or returned (dry run,
147        // the prompt, or the error for a missing `--yes`); `--yes` acts at once
148        if self.dry_run || !self.yes {
149            Process::measure_cpu(&mut stuck);
150        }
151
152        // Show stuck processes
153        if !self.json {
154            self.show_processes(&stuck);
155        }
156
157        // Dry run
158        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: &not_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        // Confirm. Same rule everywhere: --yes, or ask; --json never skips it
200        if must_confirm("unstick", self.yes, self.json, &stuck, Some(&not_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        // Attempt to unstick each process
226        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        // Count outcomes
248        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        // Output results
270        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: &not_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    /// Resolve target to processes, excluding self
316    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    /// Check if a process looks stuck, the way `proc stuck` measures it (two
327    /// readings a second apart). The `cpu_percent` of a fresh lookup is read over
328    /// a few milliseconds and is ~0 for any process, so it can't be used here.
329    fn is_stuck(&self, proc: &Process) -> bool {
330        proc.looks_stuck_now()
331    }
332
333    /// Attempt to unstick a process: gentle recovery first, termination only with --force
334    fn attempt_unstick(&self, proc: &Process) -> Outcome {
335        // For targeted processes, check if actually stuck
336        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        // On non-Unix, we can only terminate
343        #[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
386/// What the recovery sequences do to one process. A trait so a test can script
387/// every answer: the failures and the races can't be produced on demand.
388trait Steps {
389    fn is_running(&self) -> bool;
390    fn pause(&self, secs: u64);
391    fn terminate(&self) -> Result<()>;
392    fn kill(&self) -> Result<()>;
393    /// SIGCONT, wake it if stopped (the result doesn't matter)
394    #[cfg(unix)]
395    fn wake(&self);
396    /// SIGINT, like Ctrl+C
397    #[cfg(unix)]
398    fn interrupt(&self) -> Result<()>;
399    /// Whether it uses under a tenth of a core over a second. A fresh lookup's
400    /// reading is ~0 for anything, so this measures over a real window.
401    #[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    // Signals go through Process, which checks the PID wasn't reused meanwhile
423    #[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
440/// The outcome of a SIGKILL (or `taskkill /F`): a failed kill of a process
441/// that is gone anyway still counts as terminated
442fn 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/// Unix recovery: SIGCONT, then SIGINT; with `force`, SIGTERM, then SIGKILL
451#[cfg(unix)]
452fn recover(steps: &impl Steps, force: bool) -> Outcome {
453    // Step 1: SIGCONT (wake if stopped); calmed_down measures over a second
454    steps.wake();
455    if steps.calmed_down() {
456        return Outcome::Recovered;
457    }
458
459    // Step 2: SIGINT (interrupt)
460    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    // Without --force, stop here
473    if !force {
474        return Outcome::StillStuck;
475    }
476
477    // Step 3: SIGTERM (polite termination) - only with --force
478    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    // Step 4: SIGKILL (force, last resort) - only with --force
488    after_kill(steps.kill(), steps.is_running())
489}
490
491/// Windows recovery: there is nothing gentle to try, so only `force` acts
492/// (compiled on Unix for the tests only)
493#[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
509/// What one process's outcome looks like in the human output
510fn 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
520/// What one process's outcome is called in the JSON output
521fn 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
531/// The summary after the outcomes: one line for each kind that occurred
532fn 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    /// The targets that matched nothing
596    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    /// A process that answers from a script, and remembers what was done to it
616    #[derive(Default)]
617    struct Fake {
618        /// Answers to `is_running`, in order; the last one repeats
619        running: RefCell<VecDeque<bool>>,
620        /// Answers to `calmed_down`, in order; `false` once they run out
621        #[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    /// Text without colour codes, whether or not colour is on
698    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        // running after SIGINT's wait, gone after SIGTERM's
783        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        // still running after the pause, gone once the kill has been sent
861        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        // a pause with no time in it: the real wait is the same call
872        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}