Skip to main content

proc_cli/commands/
unstick.rs

1//! Unstick command - Attempt to recover stuck processes
2//!
3//! Tries gentle recovery signals. Only terminates with --force.
4//!
5//! Recovery sequence:
6//! 1. SIGCONT (wake if stopped)
7//! 2. SIGINT (interrupt, like Ctrl+C)
8//!
9//! With --force:
10//! 3. SIGTERM (polite termination request)
11//! 4. SIGKILL (force, last resort)
12//!
13//! Usage:
14//!   proc unstick           # Find and unstick all stuck processes
15//!   proc unstick :3000     # Unstick process on port 3000
16//!   proc unstick 1234      # Unstick PID 1234
17//!   proc unstick node      # Unstick stuck node processes
18
19use crate::core::{apply_filters, parse_targets, resolve_targets_excluding_self, Process};
20use crate::error::{ExitCode, ProcError, Result};
21use crate::ui::{format_duration, plural, Printer};
22use clap::Args;
23use colored::*;
24use dialoguer::Confirm;
25use serde::Serialize;
26use std::time::Duration;
27
28#[cfg(unix)]
29use nix::sys::signal::{kill, Signal};
30#[cfg(unix)]
31use nix::unistd::Pid;
32
33/// Attempt to recover stuck processes
34#[derive(Args, Debug)]
35pub struct UnstickCommand {
36    /// Target: PID, :port, or name (optional - finds all stuck if omitted)
37    target: Option<String>,
38
39    /// Minimum seconds of high CPU before considered stuck (for auto-discovery)
40    #[arg(long, short, default_value = "300")]
41    timeout: u64,
42
43    /// Force termination if recovery fails
44    #[arg(long, short = 'f')]
45    force: bool,
46
47    /// Skip confirmation prompt
48    #[arg(long, short = 'y')]
49    yes: bool,
50
51    /// Show what would be done without doing it
52    #[arg(long)]
53    dry_run: bool,
54
55    /// Show verbose output
56    #[arg(long, short = 'v')]
57    verbose: bool,
58
59    /// Output as JSON
60    #[arg(long, short = 'j')]
61    json: bool,
62
63    /// Filter by directory (defaults to current directory if no path given)
64    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
65    pub in_dir: Option<String>,
66
67    /// Filter by process name
68    #[arg(long = "by", short = 'b')]
69    pub by_name: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq)]
73enum Outcome {
74    Recovered,  // Process unstuck and still running
75    Terminated, // Had to kill it (only with --force)
76    StillStuck, // Could not recover, not terminated (no --force)
77    NotStuck,   // Process wasn't stuck to begin with
78    Failed(String),
79}
80
81impl UnstickCommand {
82    /// Executes the unstick command, attempting to recover hung processes.
83    pub fn execute(&self) -> Result<()> {
84        let printer = Printer::from_flags(self.json, self.verbose);
85
86        // Get processes to unstick
87        let mut stuck = if let Some(ref target) = self.target {
88            // Specific target
89            self.resolve_target_processes(target)?
90        } else {
91            // Auto-discover stuck processes
92            let timeout = Duration::from_secs(self.timeout);
93            Process::find_stuck(timeout)?
94        };
95
96        // Apply --in and --by filters
97        apply_filters(&mut stuck, &self.in_dir, &self.by_name);
98
99        if stuck.is_empty() {
100            // An explicit target filtered down to nothing is "not found", like other commands
101            if let Some(ref target) = self.target {
102                return Err(ProcError::ProcessNotFound(target.clone()));
103            }
104            if self.json {
105                printer.print_json(&UnstickOutput {
106                    action: "unstick",
107                    success: true,
108                    dry_run: self.dry_run,
109                    force: self.force,
110                    found: 0,
111                    recovered: 0,
112                    not_stuck: 0,
113                    still_stuck: 0,
114                    terminated: 0,
115                    failed: 0,
116                    processes: Vec::new(),
117                });
118            } else {
119                printer.success("No stuck processes found");
120            }
121            return Ok(());
122        }
123
124        // Show stuck processes
125        if !self.json {
126            self.show_processes(&stuck);
127        }
128
129        // Dry run
130        if self.dry_run {
131            if self.json {
132                printer.print_json(&UnstickOutput {
133                    action: "unstick",
134                    success: true,
135                    dry_run: true,
136                    force: self.force,
137                    found: stuck.len(),
138                    recovered: 0,
139                    not_stuck: 0,
140                    still_stuck: 0,
141                    terminated: 0,
142                    failed: 0,
143                    processes: stuck
144                        .iter()
145                        .map(|p| ProcessOutcome {
146                            pid: p.pid,
147                            name: p.name.clone(),
148                            outcome: "would_attempt".to_string(),
149                        })
150                        .collect(),
151                });
152            } else {
153                println!(
154                    "\n{} Dry run: Would attempt to unstick {} process{}",
155                    "ℹ".blue().bold(),
156                    stuck.len().to_string().cyan().bold(),
157                    plural(stuck.len())
158                );
159                if self.force {
160                    println!("  With --force: will terminate if recovery fails");
161                } else {
162                    println!("  Without --force: will only attempt recovery");
163                }
164                println!();
165            }
166            return Ok(());
167        }
168
169        // Confirm
170        if !self.yes && !self.json {
171            if self.force {
172                println!(
173                    "\n{} With --force: processes will be terminated if recovery fails.\n",
174                    "!".yellow().bold()
175                );
176            } else {
177                println!(
178                    "\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
179                    "ℹ".blue().bold()
180                );
181            }
182
183            let prompt = format!("Unstick {} process{}?", stuck.len(), plural(stuck.len()));
184
185            if !Confirm::new()
186                .with_prompt(prompt)
187                .default(false)
188                .interact()?
189            {
190                printer.warning("Aborted");
191                return Ok(());
192            }
193        }
194
195        // Attempt to unstick each process
196        let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
197
198        for proc in &stuck {
199            if !self.json {
200                print!(
201                    "  {} {} [PID {}]... ",
202                    "→".bright_black(),
203                    proc.name.white(),
204                    proc.pid.to_string().cyan()
205                );
206            }
207
208            let outcome = self.attempt_unstick(proc);
209
210            if !self.json {
211                match &outcome {
212                    Outcome::Recovered => println!("{}", "recovered".green()),
213                    Outcome::Terminated => println!("{}", "terminated".yellow()),
214                    Outcome::StillStuck => println!("{}", "still stuck".red()),
215                    Outcome::NotStuck => println!("{}", "not stuck".blue()),
216                    Outcome::Failed(e) => println!("{}: {}", "failed".red(), e),
217                }
218            }
219
220            outcomes.push((proc.clone(), outcome));
221        }
222
223        // Count outcomes
224        let recovered = outcomes
225            .iter()
226            .filter(|(_, o)| *o == Outcome::Recovered)
227            .count();
228        let terminated = outcomes
229            .iter()
230            .filter(|(_, o)| *o == Outcome::Terminated)
231            .count();
232        let still_stuck = outcomes
233            .iter()
234            .filter(|(_, o)| *o == Outcome::StillStuck)
235            .count();
236        let not_stuck = outcomes
237            .iter()
238            .filter(|(_, o)| *o == Outcome::NotStuck)
239            .count();
240        let failed = outcomes
241            .iter()
242            .filter(|(_, o)| matches!(o, Outcome::Failed(_)))
243            .count();
244
245        // Output results
246        if self.json {
247            printer.print_json(&UnstickOutput {
248                action: "unstick",
249                success: failed == 0 && still_stuck == 0,
250                dry_run: false,
251                force: self.force,
252                found: stuck.len(),
253                recovered,
254                not_stuck,
255                still_stuck,
256                terminated,
257                failed,
258                processes: outcomes
259                    .iter()
260                    .map(|(p, o)| ProcessOutcome {
261                        pid: p.pid,
262                        name: p.name.clone(),
263                        outcome: match o {
264                            Outcome::Recovered => "recovered".to_string(),
265                            Outcome::Terminated => "terminated".to_string(),
266                            Outcome::StillStuck => "still_stuck".to_string(),
267                            Outcome::NotStuck => "not_stuck".to_string(),
268                            Outcome::Failed(e) => format!("failed: {}", e),
269                        },
270                    })
271                    .collect(),
272            });
273        } else {
274            println!();
275            if recovered > 0 {
276                println!(
277                    "{} {} process{} recovered",
278                    "✓".green().bold(),
279                    recovered.to_string().cyan().bold(),
280                    plural(recovered)
281                );
282            }
283            if not_stuck > 0 {
284                println!(
285                    "{} {} process{} not stuck",
286                    "ℹ".blue().bold(),
287                    not_stuck.to_string().cyan().bold(),
288                    if not_stuck == 1 { " was" } else { "es were" }
289                );
290            }
291            if terminated > 0 {
292                println!(
293                    "{} {} process{} terminated",
294                    "!".yellow().bold(),
295                    terminated.to_string().cyan().bold(),
296                    plural(terminated)
297                );
298            }
299            if still_stuck > 0 {
300                println!(
301                    "{} {} process{} still stuck (use --force to terminate)",
302                    "✗".red().bold(),
303                    still_stuck.to_string().cyan().bold(),
304                    plural(still_stuck)
305                );
306            }
307            if failed > 0 {
308                println!(
309                    "{} {} process{} failed",
310                    "✗".red().bold(),
311                    failed.to_string().cyan().bold(),
312                    plural(failed)
313                );
314            }
315        }
316
317        if failed > 0 || still_stuck > 0 {
318            return Err(ProcError::reported(
319                ExitCode::GeneralError,
320                format!(
321                    "{} process{} still stuck, {} failed",
322                    still_stuck,
323                    plural(still_stuck),
324                    failed
325                ),
326            ));
327        }
328
329        Ok(())
330    }
331
332    /// Resolve target to processes, excluding self
333    fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
334        let targets = parse_targets(target);
335        let (processes, _) = resolve_targets_excluding_self(&targets);
336        if processes.is_empty() {
337            Err(ProcError::ProcessNotFound(target.to_string()))
338        } else {
339            Ok(processes)
340        }
341    }
342
343    /// Check if a process appears stuck (high CPU)
344    fn is_stuck(&self, proc: &Process) -> bool {
345        proc.cpu_percent > 50.0
346    }
347
348    /// Attempt to unstick a process using recovery signals
349    #[cfg(unix)]
350    fn attempt_unstick(&self, proc: &Process) -> Outcome {
351        // For targeted processes, check if actually stuck
352        if self.target.is_some() && !self.is_stuck(proc) {
353            return Outcome::NotStuck;
354        }
355
356        let pid = Pid::from_raw(proc.pid as i32);
357
358        // Step 1: SIGCONT (wake if stopped)
359        let _ = kill(pid, Signal::SIGCONT);
360        std::thread::sleep(Duration::from_secs(1));
361
362        if self.check_recovered(proc) {
363            return Outcome::Recovered;
364        }
365
366        // Step 2: SIGINT (interrupt)
367        if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
368            return Outcome::Terminated;
369        }
370        std::thread::sleep(Duration::from_secs(3));
371
372        if !proc.is_running() {
373            return Outcome::Terminated;
374        }
375        if self.check_recovered(proc) {
376            return Outcome::Recovered;
377        }
378
379        // Without --force, stop here
380        if !self.force {
381            return Outcome::StillStuck;
382        }
383
384        // Step 3: SIGTERM (polite termination) - only with --force
385        if proc.terminate().is_err() && !proc.is_running() {
386            return Outcome::Terminated;
387        }
388        std::thread::sleep(Duration::from_secs(5));
389
390        if !proc.is_running() {
391            return Outcome::Terminated;
392        }
393
394        // Step 4: SIGKILL (force, last resort) - only with --force
395        match proc.kill() {
396            Ok(()) => Outcome::Terminated,
397            Err(e) => {
398                if !proc.is_running() {
399                    Outcome::Terminated
400                } else {
401                    Outcome::Failed(e.to_string())
402                }
403            }
404        }
405    }
406
407    #[cfg(not(unix))]
408    fn attempt_unstick(&self, proc: &Process) -> Outcome {
409        // For targeted processes, check if actually stuck
410        if self.target.is_some() && !self.is_stuck(proc) {
411            return Outcome::NotStuck;
412        }
413
414        // On non-Unix, we can only terminate
415        if !self.force {
416            return Outcome::StillStuck;
417        }
418
419        if proc.terminate().is_ok() {
420            std::thread::sleep(Duration::from_secs(3));
421            if !proc.is_running() {
422                return Outcome::Terminated;
423            }
424        }
425
426        match proc.kill() {
427            Ok(()) => Outcome::Terminated,
428            Err(e) => Outcome::Failed(e.to_string()),
429        }
430    }
431
432    /// Check if process has recovered (no longer stuck)
433    #[cfg(unix)]
434    fn check_recovered(&self, proc: &Process) -> bool {
435        if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
436            current.cpu_percent < 10.0
437        } else {
438            false
439        }
440    }
441
442    fn show_processes(&self, processes: &[Process]) {
443        let label = if self.target.is_some() {
444            "Target"
445        } else {
446            "Found stuck"
447        };
448
449        println!(
450            "\n{} {} {} process{}:\n",
451            "!".yellow().bold(),
452            label,
453            processes.len().to_string().cyan().bold(),
454            plural(processes.len())
455        );
456
457        for proc in processes {
458            let uptime = proc
459                .start_time
460                .map(|st| {
461                    let now = std::time::SystemTime::now()
462                        .duration_since(std::time::UNIX_EPOCH)
463                        .map(|d| d.as_secs().saturating_sub(st))
464                        .unwrap_or(0);
465                    format_duration(now)
466                })
467                .unwrap_or_else(|| "unknown".to_string());
468
469            println!(
470                "  {} {} [PID {}] - {:.1}% CPU, running for {}",
471                "→".bright_black(),
472                proc.name.white().bold(),
473                proc.pid.to_string().cyan(),
474                proc.cpu_percent,
475                uptime.yellow()
476            );
477        }
478    }
479}
480
481#[derive(Serialize)]
482struct UnstickOutput {
483    action: &'static str,
484    success: bool,
485    dry_run: bool,
486    force: bool,
487    found: usize,
488    recovered: usize,
489    not_stuck: usize,
490    still_stuck: usize,
491    terminated: usize,
492    failed: usize,
493    processes: Vec<ProcessOutcome>,
494}
495
496#[derive(Serialize)]
497struct ProcessOutcome {
498    pid: u32,
499    name: String,
500    outcome: String,
501}