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