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