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, 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/// 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. When a person is about to be asked, the
153        // question shows them, beside the question
154        let asked_below = !self.dry_run
155            && matches!(
156                must_confirm("unstick", self.yes, self.json, &stuck, Some(&not_found)),
157                Ok(true)
158            );
159        if !self.json && !asked_below {
160            self.show_processes(&stuck);
161        }
162
163        // Dry run
164        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: &not_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        // Confirm. Same rule everywhere: --yes, or ask; --json never skips it
206        if must_confirm("unstick", self.yes, self.json, &stuck, Some(&not_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(&not_found), &question, show)? {
224                printer.warning("Aborted");
225                return Ok(());
226            }
227        }
228
229        // Attempt to unstick each process
230        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        // Count outcomes
252        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        // Output results
274        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: &not_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    /// Resolve target to processes, excluding self
320    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    /// Check if a process looks stuck, the way `proc stuck` measures it (two
331    /// readings a second apart). The `cpu_percent` of a fresh lookup is read over
332    /// a few milliseconds and is ~0 for any process, so it can't be used here.
333    fn is_stuck(&self, proc: &Process) -> bool {
334        proc.looks_stuck_now()
335    }
336
337    /// Attempt to unstick a process: gentle recovery first, termination only with --force
338    fn attempt_unstick(&self, proc: &Process) -> Outcome {
339        // For targeted processes, check if actually stuck
340        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        // On non-Unix, we can only terminate
347        #[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
390/// What the recovery sequences do to one process. A trait so a test can script
391/// every answer: the failures and the races can't be produced on demand.
392trait Steps {
393    fn is_running(&self) -> bool;
394    fn pause(&self, secs: u64);
395    fn terminate(&self) -> Result<()>;
396    fn kill(&self) -> Result<()>;
397    /// SIGCONT, wake it if stopped (the result doesn't matter)
398    #[cfg(unix)]
399    fn wake(&self);
400    /// SIGINT, like Ctrl+C
401    #[cfg(unix)]
402    fn interrupt(&self) -> Result<()>;
403    /// Whether it uses under a tenth of a core over a second. A fresh lookup's
404    /// reading is ~0 for anything, so this measures over a real window.
405    #[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    // Signals go through Process, which checks the PID wasn't reused meanwhile
427    #[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
444/// The outcome of a SIGKILL (or `taskkill /F`): a failed kill of a process
445/// that is gone anyway still counts as terminated
446fn 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/// Unix recovery: SIGCONT, then SIGINT; with `force`, SIGTERM, then SIGKILL
455#[cfg(unix)]
456fn recover(steps: &impl Steps, force: bool) -> Outcome {
457    // Step 1: SIGCONT (wake if stopped); calmed_down measures over a second
458    steps.wake();
459    if steps.calmed_down() {
460        return Outcome::Recovered;
461    }
462
463    // Step 2: SIGINT (interrupt)
464    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    // Without --force, stop here
477    if !force {
478        return Outcome::StillStuck;
479    }
480
481    // Step 3: SIGTERM (polite termination) - only with --force
482    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    // Step 4: SIGKILL (force, last resort) - only with --force
492    after_kill(steps.kill(), steps.is_running())
493}
494
495/// Windows recovery: there is nothing gentle to try, so only `force` acts
496/// (compiled on Unix for the tests only)
497#[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
513/// What one process's outcome looks like in the human output
514fn 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
524/// What one process's outcome is called in the JSON output
525fn 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
535/// The summary after the outcomes: one line for each kind that occurred
536fn 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    /// The targets that matched nothing
600    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    /// A process that answers from a script, and remembers what was done to it
620    #[derive(Default)]
621    struct Fake {
622        /// Answers to `is_running`, in order; the last one repeats
623        running: RefCell<VecDeque<bool>>,
624        /// Answers to `calmed_down`, in order; `false` once they run out
625        #[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    /// Text without colour codes, whether or not colour is on
702    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        // running after SIGINT's wait, gone after SIGTERM's
787        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        // still running after the pause, gone once the kill has been sent
865        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        // a pause with no time in it: the real wait is the same call
876        Steps::pause(&record, 0);
877        Steps::kill(&record).unwrap();
878        let _ = child.wait();
879        // Windows can list a process for a moment after it has exited (the
880        // recovery itself does not ask: a kill that worked is `Terminated`)
881        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}