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, 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/// Attempt to recover stuck processes
41#[derive(Args, Debug)]
42pub struct UnstickCommand {
43    /// Target(s): PID, :port, or name (separate arguments for several; omit to find all stuck)
44    #[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
45    target: Vec<String>,
46
47    /// Without a target: only stuck processes running longer than this many seconds
48    #[arg(long, short, default_value = "300")]
49    timeout: u64,
50
51    /// Force termination if recovery fails
52    #[arg(long, short = 'f')]
53    force: bool,
54
55    /// Skip confirmation prompt
56    #[arg(long, short = 'y')]
57    yes: bool,
58
59    /// Show what would be done without doing it
60    #[arg(long)]
61    dry_run: bool,
62
63    /// Show verbose output
64    #[arg(long, short = 'v')]
65    verbose: bool,
66
67    /// Output as JSON
68    #[arg(long, short = 'j')]
69    json: bool,
70
71    /// Filter by directory; takes the next word as the directory unless it starts with '-'
72    /// (bare --in is the current directory, --in=DIR is explicit)
73    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
74    pub in_dir: Option<String>,
75
76    /// Filter by process name
77    #[arg(long = "by", short = 'b')]
78    pub by_name: Option<String>,
79
80    /// Act only if the process still has this start time (Unix seconds, `start_time`
81    /// in its JSON): a PID the OS gave to a new process is ruled out. One PID target
82    #[arg(long = "start-time", value_name = "SECONDS", requires = "target")]
83    pub start_time: Option<u64>,
84
85    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
86    #[arg(long)]
87    pub cmd: bool,
88}
89
90#[derive(Debug, Clone, PartialEq)]
91enum Outcome {
92    Recovered,  // Process unstuck and still running
93    Terminated, // Gone: killed with --force, or ended by the SIGINT step
94    StillStuck, // Could not recover, not terminated (no --force)
95    NotStuck,   // Process wasn't stuck to begin with
96    Failed(String),
97}
98
99impl UnstickCommand {
100    /// Executes the unstick command, attempting to recover hung processes.
101    pub fn execute(&self) -> Result<()> {
102        let printer = Printer::from_flags(self.json, self.verbose);
103
104        // Get processes to unstick, and the targets that matched nothing
105        let (mut stuck, not_found) = if !self.target.is_empty() {
106            // Specific targets
107            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            // Auto-discover stuck processes
114            let timeout = Duration::from_secs(self.timeout);
115            (Process::find_stuck(timeout)?, Vec::new())
116        };
117
118        // Apply --in and --by: with targets they are guards, which say so when
119        // they rule out everything found; on the processes found stuck they
120        // only narrow the list
121        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        // Only the search can come up empty: targets that match nothing, or
130        // that the guards rule out, are errors above
131        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: &not_found,
145                    processes: Vec::new(),
146                });
147            } else {
148                printer.success("No stuck processes found");
149            }
150            return Ok(());
151        }
152
153        // Real CPU readings whenever the list is shown or returned (dry run,
154        // the prompt, or the error for a missing `--yes`); `--yes` acts at once
155        if self.dry_run || !self.yes {
156            Process::measure_cpu(&mut stuck);
157        }
158
159        // Show stuck processes. When a person is about to be asked, the
160        // question shows them, beside the question
161        let asked_below = !self.dry_run
162            && matches!(
163                must_confirm("unstick", self.yes, self.json, &stuck, Some(&not_found)),
164                Ok(true)
165            );
166        if !self.json && !asked_below {
167            self.show_processes(&stuck);
168        }
169
170        // Dry run
171        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: &not_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        // Confirm. Same rule everywhere: --yes, or ask; --json never skips it
213        if must_confirm("unstick", self.yes, self.json, &stuck, Some(&not_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(&not_found), &question, show)? {
231                printer.warning("Aborted");
232                return Ok(());
233            }
234        }
235
236        // Attempt to unstick each process
237        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        // Count outcomes
259        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        // Output results
281        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: &not_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    /// Resolve target to processes, excluding self
327    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    /// Check if a process looks stuck, the way `proc stuck` measures it (two
338    /// readings a second apart). The `cpu_percent` of a fresh lookup is read over
339    /// a few milliseconds and is ~0 for any process, so it can't be used here.
340    fn is_stuck(&self, proc: &Process) -> bool {
341        proc.looks_stuck_now()
342    }
343
344    /// Attempt to unstick a process: gentle recovery first, termination only with --force
345    fn attempt_unstick(&self, proc: &Process) -> Outcome {
346        // For targeted processes, check if actually stuck
347        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        // On non-Unix, we can only terminate
354        #[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
397/// What the recovery sequences do to one process. A trait so a test can script
398/// every answer: the failures and the races can't be produced on demand.
399trait Steps {
400    fn is_running(&self) -> bool;
401    fn pause(&self, secs: u64);
402    fn terminate(&self) -> Result<()>;
403    fn kill(&self) -> Result<()>;
404    /// SIGCONT, wake it if stopped (the result doesn't matter)
405    #[cfg(unix)]
406    fn wake(&self);
407    /// SIGINT, like Ctrl+C
408    #[cfg(unix)]
409    fn interrupt(&self) -> Result<()>;
410    /// Whether it uses under a tenth of a core over a second. A fresh lookup's
411    /// reading is ~0 for anything, so this measures over a real window.
412    #[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    // Signals go through Process, which checks the PID wasn't reused meanwhile
434    #[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
451/// The outcome of a SIGKILL (or `taskkill /F`): a failed kill of a process
452/// that is gone anyway still counts as terminated
453fn 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/// Unix recovery: SIGCONT, then SIGINT; with `force`, SIGTERM, then SIGKILL
462#[cfg(unix)]
463fn recover(steps: &impl Steps, force: bool) -> Outcome {
464    // Step 1: SIGCONT (wake if stopped); calmed_down measures over a second
465    steps.wake();
466    if steps.calmed_down() {
467        return Outcome::Recovered;
468    }
469
470    // Step 2: SIGINT (interrupt)
471    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    // Without --force, stop here
484    if !force {
485        return Outcome::StillStuck;
486    }
487
488    // Step 3: SIGTERM (polite termination) - only with --force
489    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    // Step 4: SIGKILL (force, last resort) - only with --force
499    after_kill(steps.kill(), steps.is_running())
500}
501
502/// Windows recovery: there is nothing gentle to try, so only `force` acts
503/// (compiled on Unix for the tests only)
504#[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
520/// What one process's outcome looks like in the human output
521fn 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
531/// What one process's outcome is called in the JSON output
532fn 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
542/// The summary after the outcomes: one line for each kind that occurred
543fn 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    /// The targets that matched nothing
607    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    /// A process that answers from a script, and remembers what was done to it
627    #[derive(Default)]
628    struct Fake {
629        /// Answers to `is_running`, in order; the last one repeats
630        running: RefCell<VecDeque<bool>>,
631        /// Answers to `calmed_down`, in order; `false` once they run out
632        #[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    /// Text without colour codes, whether or not colour is on
709    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        // running after SIGINT's wait, gone after SIGTERM's
794        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        // still running after the pause, gone once the kill has been sent
872        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        // a pause with no time in it: the real wait is the same call
883        Steps::pause(&record, 0);
884        Steps::kill(&record).unwrap();
885        let _ = child.wait();
886        // Windows can list a process for a moment after it has exited (the
887        // recovery itself does not ask: a kill that worked is `Terminated`)
888        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}