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