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::{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    /// 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
313    fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
314        resolve_target(target).map_err(|_| ProcError::ProcessNotFound(target.to_string()))
315    }
316
317    /// Check if a process appears stuck (high CPU)
318    fn is_stuck(&self, proc: &Process) -> bool {
319        proc.cpu_percent > 50.0
320    }
321
322    /// Attempt to unstick a process using recovery signals
323    #[cfg(unix)]
324    fn attempt_unstick(&self, proc: &Process) -> Outcome {
325        // For targeted processes, check if actually stuck
326        if self.target.is_some() && !self.is_stuck(proc) {
327            return Outcome::NotStuck;
328        }
329
330        let pid = Pid::from_raw(proc.pid as i32);
331
332        // Step 1: SIGCONT (wake if stopped)
333        let _ = kill(pid, Signal::SIGCONT);
334        std::thread::sleep(Duration::from_secs(1));
335
336        if self.check_recovered(proc) {
337            return Outcome::Recovered;
338        }
339
340        // Step 2: SIGINT (interrupt)
341        if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
342            return Outcome::Terminated;
343        }
344        std::thread::sleep(Duration::from_secs(3));
345
346        if !proc.is_running() {
347            return Outcome::Terminated;
348        }
349        if self.check_recovered(proc) {
350            return Outcome::Recovered;
351        }
352
353        // Without --force, stop here
354        if !self.force {
355            return Outcome::StillStuck;
356        }
357
358        // Step 3: SIGTERM (polite termination) - only with --force
359        if proc.terminate().is_err() && !proc.is_running() {
360            return Outcome::Terminated;
361        }
362        std::thread::sleep(Duration::from_secs(5));
363
364        if !proc.is_running() {
365            return Outcome::Terminated;
366        }
367
368        // Step 4: SIGKILL (force, last resort) - only with --force
369        match proc.kill() {
370            Ok(()) => Outcome::Terminated,
371            Err(e) => {
372                if !proc.is_running() {
373                    Outcome::Terminated
374                } else {
375                    Outcome::Failed(e.to_string())
376                }
377            }
378        }
379    }
380
381    #[cfg(not(unix))]
382    fn attempt_unstick(&self, proc: &Process) -> Outcome {
383        // For targeted processes, check if actually stuck
384        if self.target.is_some() && !self.is_stuck(proc) {
385            return Outcome::NotStuck;
386        }
387
388        // On non-Unix, we can only terminate
389        if !self.force {
390            return Outcome::StillStuck;
391        }
392
393        if proc.terminate().is_ok() {
394            std::thread::sleep(Duration::from_secs(3));
395            if !proc.is_running() {
396                return Outcome::Terminated;
397            }
398        }
399
400        match proc.kill() {
401            Ok(()) => Outcome::Terminated,
402            Err(e) => Outcome::Failed(e.to_string()),
403        }
404    }
405
406    /// Check if process has recovered (no longer stuck)
407    #[cfg(unix)]
408    fn check_recovered(&self, proc: &Process) -> bool {
409        if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
410            current.cpu_percent < 10.0
411        } else {
412            false
413        }
414    }
415
416    fn show_processes(&self, processes: &[Process]) {
417        let label = if self.target.is_some() {
418            "Target"
419        } else {
420            "Found stuck"
421        };
422
423        println!(
424            "\n{} {} {} process{}:\n",
425            "!".yellow().bold(),
426            label,
427            processes.len().to_string().cyan().bold(),
428            if processes.len() == 1 { "" } else { "es" }
429        );
430
431        for proc in processes {
432            let uptime = proc
433                .start_time
434                .map(|st| {
435                    let now = std::time::SystemTime::now()
436                        .duration_since(std::time::UNIX_EPOCH)
437                        .map(|d| d.as_secs().saturating_sub(st))
438                        .unwrap_or(0);
439                    format_duration(now)
440                })
441                .unwrap_or_else(|| "unknown".to_string());
442
443            println!(
444                "  {} {} [PID {}] - {:.1}% CPU, running for {}",
445                "→".bright_black(),
446                proc.name.white().bold(),
447                proc.pid.to_string().cyan(),
448                proc.cpu_percent,
449                uptime.yellow()
450            );
451        }
452    }
453}
454
455fn format_duration(secs: u64) -> String {
456    if secs < 60 {
457        format!("{}s", secs)
458    } else if secs < 3600 {
459        format!("{}m", secs / 60)
460    } else if secs < 86400 {
461        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
462    } else {
463        format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
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}