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::{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            if self.json {
101                printer.print_json(&UnstickOutput {
102                    action: "unstick",
103                    success: true,
104                    dry_run: self.dry_run,
105                    force: self.force,
106                    found: 0,
107                    recovered: 0,
108                    not_stuck: 0,
109                    still_stuck: 0,
110                    terminated: 0,
111                    failed: 0,
112                    processes: Vec::new(),
113                });
114            } else if self.target.is_some() {
115                printer.warning("Target process not found");
116            } else {
117                printer.success("No stuck processes found");
118            }
119            return Ok(());
120        }
121
122        // Show stuck processes
123        if !self.json {
124            self.show_processes(&stuck);
125        }
126
127        // Dry run
128        if self.dry_run {
129            if self.json {
130                printer.print_json(&UnstickOutput {
131                    action: "unstick",
132                    success: true,
133                    dry_run: true,
134                    force: self.force,
135                    found: stuck.len(),
136                    recovered: 0,
137                    not_stuck: 0,
138                    still_stuck: 0,
139                    terminated: 0,
140                    failed: 0,
141                    processes: stuck
142                        .iter()
143                        .map(|p| ProcessOutcome {
144                            pid: p.pid,
145                            name: p.name.clone(),
146                            outcome: "would_attempt".to_string(),
147                        })
148                        .collect(),
149                });
150            } else {
151                println!(
152                    "\n{} Dry run: Would attempt to unstick {} process{}",
153                    "ℹ".blue().bold(),
154                    stuck.len().to_string().cyan().bold(),
155                    plural(stuck.len())
156                );
157                if self.force {
158                    println!("  With --force: will terminate if recovery fails");
159                } else {
160                    println!("  Without --force: will only attempt recovery");
161                }
162                println!();
163            }
164            return Ok(());
165        }
166
167        // Confirm
168        if !self.yes && !self.json {
169            if self.force {
170                println!(
171                    "\n{} With --force: processes will be terminated if recovery fails.\n",
172                    "!".yellow().bold()
173                );
174            } else {
175                println!(
176                    "\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
177                    "ℹ".blue().bold()
178                );
179            }
180
181            let prompt = format!("Unstick {} process{}?", stuck.len(), plural(stuck.len()));
182
183            if !Confirm::new()
184                .with_prompt(prompt)
185                .default(false)
186                .interact()?
187            {
188                printer.warning("Aborted");
189                return Ok(());
190            }
191        }
192
193        // Attempt to unstick each process
194        let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
195
196        for proc in &stuck {
197            if !self.json {
198                print!(
199                    "  {} {} [PID {}]... ",
200                    "→".bright_black(),
201                    proc.name.white(),
202                    proc.pid.to_string().cyan()
203                );
204            }
205
206            let outcome = self.attempt_unstick(proc);
207
208            if !self.json {
209                match &outcome {
210                    Outcome::Recovered => println!("{}", "recovered".green()),
211                    Outcome::Terminated => println!("{}", "terminated".yellow()),
212                    Outcome::StillStuck => println!("{}", "still stuck".red()),
213                    Outcome::NotStuck => println!("{}", "not stuck".blue()),
214                    Outcome::Failed(e) => println!("{}: {}", "failed".red(), e),
215                }
216            }
217
218            outcomes.push((proc.clone(), outcome));
219        }
220
221        // Count outcomes
222        let recovered = outcomes
223            .iter()
224            .filter(|(_, o)| *o == Outcome::Recovered)
225            .count();
226        let terminated = outcomes
227            .iter()
228            .filter(|(_, o)| *o == Outcome::Terminated)
229            .count();
230        let still_stuck = outcomes
231            .iter()
232            .filter(|(_, o)| *o == Outcome::StillStuck)
233            .count();
234        let not_stuck = outcomes
235            .iter()
236            .filter(|(_, o)| *o == Outcome::NotStuck)
237            .count();
238        let failed = outcomes
239            .iter()
240            .filter(|(_, o)| matches!(o, Outcome::Failed(_)))
241            .count();
242
243        // Output results
244        if self.json {
245            printer.print_json(&UnstickOutput {
246                action: "unstick",
247                success: failed == 0 && still_stuck == 0,
248                dry_run: false,
249                force: self.force,
250                found: stuck.len(),
251                recovered,
252                not_stuck,
253                still_stuck,
254                terminated,
255                failed,
256                processes: outcomes
257                    .iter()
258                    .map(|(p, o)| ProcessOutcome {
259                        pid: p.pid,
260                        name: p.name.clone(),
261                        outcome: match o {
262                            Outcome::Recovered => "recovered".to_string(),
263                            Outcome::Terminated => "terminated".to_string(),
264                            Outcome::StillStuck => "still_stuck".to_string(),
265                            Outcome::NotStuck => "not_stuck".to_string(),
266                            Outcome::Failed(e) => format!("failed: {}", e),
267                        },
268                    })
269                    .collect(),
270            });
271        } else {
272            println!();
273            if recovered > 0 {
274                println!(
275                    "{} {} process{} recovered",
276                    "✓".green().bold(),
277                    recovered.to_string().cyan().bold(),
278                    plural(recovered)
279                );
280            }
281            if not_stuck > 0 {
282                println!(
283                    "{} {} process{} not stuck",
284                    "ℹ".blue().bold(),
285                    not_stuck.to_string().cyan().bold(),
286                    if not_stuck == 1 { " was" } else { "es were" }
287                );
288            }
289            if terminated > 0 {
290                println!(
291                    "{} {} process{} terminated",
292                    "!".yellow().bold(),
293                    terminated.to_string().cyan().bold(),
294                    plural(terminated)
295                );
296            }
297            if still_stuck > 0 {
298                println!(
299                    "{} {} process{} still stuck (use --force to terminate)",
300                    "✗".red().bold(),
301                    still_stuck.to_string().cyan().bold(),
302                    plural(still_stuck)
303                );
304            }
305            if failed > 0 {
306                println!(
307                    "{} {} process{} failed",
308                    "✗".red().bold(),
309                    failed.to_string().cyan().bold(),
310                    plural(failed)
311                );
312            }
313        }
314
315        Ok(())
316    }
317
318    /// Resolve target to processes, excluding self
319    fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
320        let targets = parse_targets(target);
321        let (processes, _) = resolve_targets_excluding_self(&targets);
322        if processes.is_empty() {
323            Err(ProcError::ProcessNotFound(target.to_string()))
324        } else {
325            Ok(processes)
326        }
327    }
328
329    /// Check if a process appears stuck (high CPU)
330    fn is_stuck(&self, proc: &Process) -> bool {
331        proc.cpu_percent > 50.0
332    }
333
334    /// Attempt to unstick a process using recovery signals
335    #[cfg(unix)]
336    fn attempt_unstick(&self, proc: &Process) -> Outcome {
337        // For targeted processes, check if actually stuck
338        if self.target.is_some() && !self.is_stuck(proc) {
339            return Outcome::NotStuck;
340        }
341
342        let pid = Pid::from_raw(proc.pid as i32);
343
344        // Step 1: SIGCONT (wake if stopped)
345        let _ = kill(pid, Signal::SIGCONT);
346        std::thread::sleep(Duration::from_secs(1));
347
348        if self.check_recovered(proc) {
349            return Outcome::Recovered;
350        }
351
352        // Step 2: SIGINT (interrupt)
353        if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
354            return Outcome::Terminated;
355        }
356        std::thread::sleep(Duration::from_secs(3));
357
358        if !proc.is_running() {
359            return Outcome::Terminated;
360        }
361        if self.check_recovered(proc) {
362            return Outcome::Recovered;
363        }
364
365        // Without --force, stop here
366        if !self.force {
367            return Outcome::StillStuck;
368        }
369
370        // Step 3: SIGTERM (polite termination) - only with --force
371        if proc.terminate().is_err() && !proc.is_running() {
372            return Outcome::Terminated;
373        }
374        std::thread::sleep(Duration::from_secs(5));
375
376        if !proc.is_running() {
377            return Outcome::Terminated;
378        }
379
380        // Step 4: SIGKILL (force, last resort) - only with --force
381        match proc.kill() {
382            Ok(()) => Outcome::Terminated,
383            Err(e) => {
384                if !proc.is_running() {
385                    Outcome::Terminated
386                } else {
387                    Outcome::Failed(e.to_string())
388                }
389            }
390        }
391    }
392
393    #[cfg(not(unix))]
394    fn attempt_unstick(&self, proc: &Process) -> Outcome {
395        // For targeted processes, check if actually stuck
396        if self.target.is_some() && !self.is_stuck(proc) {
397            return Outcome::NotStuck;
398        }
399
400        // On non-Unix, we can only terminate
401        if !self.force {
402            return Outcome::StillStuck;
403        }
404
405        if proc.terminate().is_ok() {
406            std::thread::sleep(Duration::from_secs(3));
407            if !proc.is_running() {
408                return Outcome::Terminated;
409            }
410        }
411
412        match proc.kill() {
413            Ok(()) => Outcome::Terminated,
414            Err(e) => Outcome::Failed(e.to_string()),
415        }
416    }
417
418    /// Check if process has recovered (no longer stuck)
419    #[cfg(unix)]
420    fn check_recovered(&self, proc: &Process) -> bool {
421        if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
422            current.cpu_percent < 10.0
423        } else {
424            false
425        }
426    }
427
428    fn show_processes(&self, processes: &[Process]) {
429        let label = if self.target.is_some() {
430            "Target"
431        } else {
432            "Found stuck"
433        };
434
435        println!(
436            "\n{} {} {} process{}:\n",
437            "!".yellow().bold(),
438            label,
439            processes.len().to_string().cyan().bold(),
440            plural(processes.len())
441        );
442
443        for proc in processes {
444            let uptime = proc
445                .start_time
446                .map(|st| {
447                    let now = std::time::SystemTime::now()
448                        .duration_since(std::time::UNIX_EPOCH)
449                        .map(|d| d.as_secs().saturating_sub(st))
450                        .unwrap_or(0);
451                    format_duration(now)
452                })
453                .unwrap_or_else(|| "unknown".to_string());
454
455            println!(
456                "  {} {} [PID {}] - {:.1}% CPU, running for {}",
457                "→".bright_black(),
458                proc.name.white().bold(),
459                proc.pid.to_string().cyan(),
460                proc.cpu_percent,
461                uptime.yellow()
462            );
463        }
464    }
465}
466
467#[derive(Serialize)]
468struct UnstickOutput {
469    action: &'static str,
470    success: bool,
471    dry_run: bool,
472    force: bool,
473    found: usize,
474    recovered: usize,
475    not_stuck: usize,
476    still_stuck: usize,
477    terminated: usize,
478    failed: usize,
479    processes: Vec<ProcessOutcome>,
480}
481
482#[derive(Serialize)]
483struct ProcessOutcome {
484    pid: u32,
485    name: String,
486    outcome: String,
487}