1use 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::path::PathBuf;
27use std::time::Duration;
28
29#[cfg(unix)]
30use nix::sys::signal::{kill, Signal};
31#[cfg(unix)]
32use nix::unistd::Pid;
33
34#[derive(Args, Debug)]
36pub struct UnstickCommand {
37 target: Option<String>,
39
40 #[arg(long, short, default_value = "300")]
42 timeout: u64,
43
44 #[arg(long, short = 'f')]
46 force: bool,
47
48 #[arg(long, short = 'y')]
50 yes: bool,
51
52 #[arg(long)]
54 dry_run: bool,
55
56 #[arg(long, short)]
58 json: bool,
59
60 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
62 pub in_dir: Option<String>,
63
64 #[arg(long = "by", short = 'b')]
66 pub by_name: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
70enum Outcome {
71 Recovered, Terminated, StillStuck, NotStuck, Failed(String),
76}
77
78impl UnstickCommand {
79 pub fn execute(&self) -> Result<()> {
81 let format = if self.json {
82 OutputFormat::Json
83 } else {
84 OutputFormat::Human
85 };
86 let printer = Printer::new(format, false);
87
88 let mut stuck = if let Some(ref target) = self.target {
90 self.resolve_target_processes(target)?
92 } else {
93 let timeout = Duration::from_secs(self.timeout);
95 Process::find_stuck(timeout)?
96 };
97
98 let in_dir_filter = resolve_in_dir(&self.in_dir);
100 stuck.retain(|p| {
101 if let Some(ref dir_path) = in_dir_filter {
102 if let Some(ref cwd) = p.cwd {
103 if !PathBuf::from(cwd).starts_with(dir_path) {
104 return false;
105 }
106 } else {
107 return false;
108 }
109 }
110 if let Some(ref name) = self.by_name {
111 if !p.name.to_lowercase().contains(&name.to_lowercase()) {
112 return false;
113 }
114 }
115 true
116 });
117
118 if stuck.is_empty() {
119 if self.json {
120 printer.print_json(&UnstickOutput {
121 action: "unstick",
122 success: true,
123 dry_run: self.dry_run,
124 force: self.force,
125 found: 0,
126 recovered: 0,
127 not_stuck: 0,
128 still_stuck: 0,
129 terminated: 0,
130 failed: 0,
131 processes: Vec::new(),
132 });
133 } else if self.target.is_some() {
134 printer.warning("Target process not found");
135 } else {
136 printer.success("No stuck processes found");
137 }
138 return Ok(());
139 }
140
141 if !self.json {
143 self.show_processes(&stuck);
144 }
145
146 if self.dry_run {
148 if self.json {
149 printer.print_json(&UnstickOutput {
150 action: "unstick",
151 success: true,
152 dry_run: true,
153 force: self.force,
154 found: stuck.len(),
155 recovered: 0,
156 not_stuck: 0,
157 still_stuck: 0,
158 terminated: 0,
159 failed: 0,
160 processes: stuck
161 .iter()
162 .map(|p| ProcessOutcome {
163 pid: p.pid,
164 name: p.name.clone(),
165 outcome: "would_attempt".to_string(),
166 })
167 .collect(),
168 });
169 } else {
170 println!(
171 "\n{} Dry run: Would attempt to unstick {} process{}",
172 "ℹ".blue().bold(),
173 stuck.len().to_string().cyan().bold(),
174 if stuck.len() == 1 { "" } else { "es" }
175 );
176 if self.force {
177 println!(" With --force: will terminate if recovery fails");
178 } else {
179 println!(" Without --force: will only attempt recovery");
180 }
181 println!();
182 }
183 return Ok(());
184 }
185
186 if !self.yes && !self.json {
188 if self.force {
189 println!(
190 "\n{} With --force: processes will be terminated if recovery fails.\n",
191 "!".yellow().bold()
192 );
193 } else {
194 println!(
195 "\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
196 "ℹ".blue().bold()
197 );
198 }
199
200 let prompt = format!(
201 "Unstick {} process{}?",
202 stuck.len(),
203 if stuck.len() == 1 { "" } else { "es" }
204 );
205
206 if !Confirm::new()
207 .with_prompt(prompt)
208 .default(false)
209 .interact()?
210 {
211 printer.warning("Aborted");
212 return Ok(());
213 }
214 }
215
216 let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
218
219 for proc in &stuck {
220 if !self.json {
221 print!(
222 " {} {} [PID {}]... ",
223 "→".bright_black(),
224 proc.name.white(),
225 proc.pid.to_string().cyan()
226 );
227 }
228
229 let outcome = self.attempt_unstick(proc);
230
231 if !self.json {
232 match &outcome {
233 Outcome::Recovered => println!("{}", "recovered".green()),
234 Outcome::Terminated => println!("{}", "terminated".yellow()),
235 Outcome::StillStuck => println!("{}", "still stuck".red()),
236 Outcome::NotStuck => println!("{}", "not stuck".blue()),
237 Outcome::Failed(e) => println!("{}: {}", "failed".red(), e),
238 }
239 }
240
241 outcomes.push((proc.clone(), outcome));
242 }
243
244 let recovered = outcomes
246 .iter()
247 .filter(|(_, o)| *o == Outcome::Recovered)
248 .count();
249 let terminated = outcomes
250 .iter()
251 .filter(|(_, o)| *o == Outcome::Terminated)
252 .count();
253 let still_stuck = outcomes
254 .iter()
255 .filter(|(_, o)| *o == Outcome::StillStuck)
256 .count();
257 let not_stuck = outcomes
258 .iter()
259 .filter(|(_, o)| *o == Outcome::NotStuck)
260 .count();
261 let failed = outcomes
262 .iter()
263 .filter(|(_, o)| matches!(o, Outcome::Failed(_)))
264 .count();
265
266 if self.json {
268 printer.print_json(&UnstickOutput {
269 action: "unstick",
270 success: failed == 0 && still_stuck == 0,
271 dry_run: false,
272 force: self.force,
273 found: stuck.len(),
274 recovered,
275 not_stuck,
276 still_stuck,
277 terminated,
278 failed,
279 processes: outcomes
280 .iter()
281 .map(|(p, o)| ProcessOutcome {
282 pid: p.pid,
283 name: p.name.clone(),
284 outcome: match o {
285 Outcome::Recovered => "recovered".to_string(),
286 Outcome::Terminated => "terminated".to_string(),
287 Outcome::StillStuck => "still_stuck".to_string(),
288 Outcome::NotStuck => "not_stuck".to_string(),
289 Outcome::Failed(e) => format!("failed: {}", e),
290 },
291 })
292 .collect(),
293 });
294 } else {
295 println!();
296 if recovered > 0 {
297 println!(
298 "{} {} process{} recovered",
299 "✓".green().bold(),
300 recovered.to_string().cyan().bold(),
301 if recovered == 1 { "" } else { "es" }
302 );
303 }
304 if not_stuck > 0 {
305 println!(
306 "{} {} process{} not stuck",
307 "ℹ".blue().bold(),
308 not_stuck.to_string().cyan().bold(),
309 if not_stuck == 1 { " was" } else { "es were" }
310 );
311 }
312 if terminated > 0 {
313 println!(
314 "{} {} process{} terminated",
315 "!".yellow().bold(),
316 terminated.to_string().cyan().bold(),
317 if terminated == 1 { "" } else { "es" }
318 );
319 }
320 if still_stuck > 0 {
321 println!(
322 "{} {} process{} still stuck (use --force to terminate)",
323 "✗".red().bold(),
324 still_stuck.to_string().cyan().bold(),
325 if still_stuck == 1 { "" } else { "es" }
326 );
327 }
328 if failed > 0 {
329 println!(
330 "{} {} process{} failed",
331 "✗".red().bold(),
332 failed.to_string().cyan().bold(),
333 if failed == 1 { "" } else { "es" }
334 );
335 }
336 }
337
338 Ok(())
339 }
340
341 fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
343 let targets = parse_targets(target);
344 let (processes, _) = resolve_targets_excluding_self(&targets);
345 if processes.is_empty() {
346 Err(ProcError::ProcessNotFound(target.to_string()))
347 } else {
348 Ok(processes)
349 }
350 }
351
352 fn is_stuck(&self, proc: &Process) -> bool {
354 proc.cpu_percent > 50.0
355 }
356
357 #[cfg(unix)]
359 fn attempt_unstick(&self, proc: &Process) -> Outcome {
360 if self.target.is_some() && !self.is_stuck(proc) {
362 return Outcome::NotStuck;
363 }
364
365 let pid = Pid::from_raw(proc.pid as i32);
366
367 let _ = kill(pid, Signal::SIGCONT);
369 std::thread::sleep(Duration::from_secs(1));
370
371 if self.check_recovered(proc) {
372 return Outcome::Recovered;
373 }
374
375 if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
377 return Outcome::Terminated;
378 }
379 std::thread::sleep(Duration::from_secs(3));
380
381 if !proc.is_running() {
382 return Outcome::Terminated;
383 }
384 if self.check_recovered(proc) {
385 return Outcome::Recovered;
386 }
387
388 if !self.force {
390 return Outcome::StillStuck;
391 }
392
393 if proc.terminate().is_err() && !proc.is_running() {
395 return Outcome::Terminated;
396 }
397 std::thread::sleep(Duration::from_secs(5));
398
399 if !proc.is_running() {
400 return Outcome::Terminated;
401 }
402
403 match proc.kill() {
405 Ok(()) => Outcome::Terminated,
406 Err(e) => {
407 if !proc.is_running() {
408 Outcome::Terminated
409 } else {
410 Outcome::Failed(e.to_string())
411 }
412 }
413 }
414 }
415
416 #[cfg(not(unix))]
417 fn attempt_unstick(&self, proc: &Process) -> Outcome {
418 if self.target.is_some() && !self.is_stuck(proc) {
420 return Outcome::NotStuck;
421 }
422
423 if !self.force {
425 return Outcome::StillStuck;
426 }
427
428 if proc.terminate().is_ok() {
429 std::thread::sleep(Duration::from_secs(3));
430 if !proc.is_running() {
431 return Outcome::Terminated;
432 }
433 }
434
435 match proc.kill() {
436 Ok(()) => Outcome::Terminated,
437 Err(e) => Outcome::Failed(e.to_string()),
438 }
439 }
440
441 #[cfg(unix)]
443 fn check_recovered(&self, proc: &Process) -> bool {
444 if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
445 current.cpu_percent < 10.0
446 } else {
447 false
448 }
449 }
450
451 fn show_processes(&self, processes: &[Process]) {
452 let label = if self.target.is_some() {
453 "Target"
454 } else {
455 "Found stuck"
456 };
457
458 println!(
459 "\n{} {} {} process{}:\n",
460 "!".yellow().bold(),
461 label,
462 processes.len().to_string().cyan().bold(),
463 if processes.len() == 1 { "" } else { "es" }
464 );
465
466 for proc in processes {
467 let uptime = proc
468 .start_time
469 .map(|st| {
470 let now = std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .map(|d| d.as_secs().saturating_sub(st))
473 .unwrap_or(0);
474 format_duration(now)
475 })
476 .unwrap_or_else(|| "unknown".to_string());
477
478 println!(
479 " {} {} [PID {}] - {:.1}% CPU, running for {}",
480 "→".bright_black(),
481 proc.name.white().bold(),
482 proc.pid.to_string().cyan(),
483 proc.cpu_percent,
484 uptime.yellow()
485 );
486 }
487 }
488}
489
490fn format_duration(secs: u64) -> String {
491 if secs < 60 {
492 format!("{}s", secs)
493 } else if secs < 3600 {
494 format!("{}m", secs / 60)
495 } else if secs < 86400 {
496 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
497 } else {
498 format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
499 }
500}
501
502#[derive(Serialize)]
503struct UnstickOutput {
504 action: &'static str,
505 success: bool,
506 dry_run: bool,
507 force: bool,
508 found: usize,
509 recovered: usize,
510 not_stuck: usize,
511 still_stuck: usize,
512 terminated: usize,
513 failed: usize,
514 processes: Vec<ProcessOutcome>,
515}
516
517#[derive(Serialize)]
518struct ProcessOutcome {
519 pid: u32,
520 name: String,
521 outcome: String,
522}
523
524fn resolve_in_dir(in_dir: &Option<String>) -> Option<PathBuf> {
525 in_dir.as_ref().map(|p| {
526 if p == "." {
527 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
528 } else {
529 let path = PathBuf::from(p);
530 if path.is_relative() {
531 std::env::current_dir()
532 .unwrap_or_else(|_| PathBuf::from("."))
533 .join(path)
534 } else {
535 path
536 }
537 }
538 })
539}