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