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