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() && !proc.is_running() {
341            return Outcome::Terminated;
342        }
343        std::thread::sleep(Duration::from_secs(3));
344
345        if !proc.is_running() {
346            return Outcome::Terminated;
347        }
348        if self.check_recovered(proc) {
349            return Outcome::Recovered;
350        }
351
352        // Without --force, stop here
353        if !self.force {
354            return Outcome::StillStuck;
355        }
356
357        // Step 3: SIGTERM (polite termination) - only with --force
358        if proc.terminate().is_err() && !proc.is_running() {
359            return Outcome::Terminated;
360        }
361        std::thread::sleep(Duration::from_secs(5));
362
363        if !proc.is_running() {
364            return Outcome::Terminated;
365        }
366
367        // Step 4: SIGKILL (force, last resort) - only with --force
368        match proc.kill() {
369            Ok(()) => Outcome::Terminated,
370            Err(e) => {
371                if !proc.is_running() {
372                    Outcome::Terminated
373                } else {
374                    Outcome::Failed(e.to_string())
375                }
376            }
377        }
378    }
379
380    #[cfg(not(unix))]
381    fn attempt_unstick(&self, proc: &Process) -> Outcome {
382        // For targeted processes, check if actually stuck
383        if self.target.is_some() && !self.is_stuck(proc) {
384            return Outcome::NotStuck;
385        }
386
387        // On non-Unix, we can only terminate
388        if !self.force {
389            return Outcome::StillStuck;
390        }
391
392        if proc.terminate().is_ok() {
393            std::thread::sleep(Duration::from_secs(3));
394            if !proc.is_running() {
395                return Outcome::Terminated;
396            }
397        }
398
399        match proc.kill() {
400            Ok(()) => Outcome::Terminated,
401            Err(e) => Outcome::Failed(e.to_string()),
402        }
403    }
404
405    /// Check if process has recovered (no longer stuck)
406    #[cfg(unix)]
407    fn check_recovered(&self, proc: &Process) -> bool {
408        if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
409            current.cpu_percent < 10.0
410        } else {
411            false
412        }
413    }
414
415    fn show_processes(&self, processes: &[Process]) {
416        let label = if self.target.is_some() {
417            "Target"
418        } else {
419            "Found stuck"
420        };
421
422        println!(
423            "\n{} {} {} process{}:\n",
424            "!".yellow().bold(),
425            label,
426            processes.len().to_string().cyan().bold(),
427            if processes.len() == 1 { "" } else { "es" }
428        );
429
430        for proc in processes {
431            let uptime = proc
432                .start_time
433                .map(|st| {
434                    let now = std::time::SystemTime::now()
435                        .duration_since(std::time::UNIX_EPOCH)
436                        .map(|d| d.as_secs().saturating_sub(st))
437                        .unwrap_or(0);
438                    format_duration(now)
439                })
440                .unwrap_or_else(|| "unknown".to_string());
441
442            println!(
443                "  {} {} [PID {}] - {:.1}% CPU, running for {}",
444                "→".bright_black(),
445                proc.name.white().bold(),
446                proc.pid.to_string().cyan(),
447                proc.cpu_percent,
448                uptime.yellow()
449            );
450        }
451    }
452}
453
454fn format_duration(secs: u64) -> String {
455    if secs < 60 {
456        format!("{}s", secs)
457    } else if secs < 3600 {
458        format!("{}m", secs / 60)
459    } else if secs < 86400 {
460        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
461    } else {
462        format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
463    }
464}
465
466#[derive(Serialize)]
467struct UnstickOutput {
468    action: &'static str,
469    success: bool,
470    dry_run: bool,
471    force: bool,
472    found: usize,
473    recovered: usize,
474    not_stuck: usize,
475    still_stuck: usize,
476    terminated: usize,
477    failed: usize,
478    processes: Vec<ProcessOutcome>,
479}
480
481#[derive(Serialize)]
482struct ProcessOutcome {
483    pid: u32,
484    name: String,
485    outcome: String,
486}