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::time::Duration;
27
28#[cfg(unix)]
29use nix::sys::signal::{kill, Signal};
30#[cfg(unix)]
31use nix::unistd::Pid;
32
33#[derive(Args, Debug)]
35pub struct UnstickCommand {
36 target: Option<String>,
38
39 #[arg(long, short, default_value = "300")]
41 timeout: u64,
42
43 #[arg(long, short = 'f')]
45 force: bool,
46
47 #[arg(long, short = 'y')]
49 yes: bool,
50
51 #[arg(long)]
53 dry_run: bool,
54
55 #[arg(long, short)]
57 json: bool,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61enum Outcome {
62 Recovered, Terminated, StillStuck, NotStuck, Failed(String),
67}
68
69impl UnstickCommand {
70 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 let stuck = if let Some(ref target) = self.target {
81 self.resolve_target_processes(target)?
83 } else {
84 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 if !self.json {
114 self.show_processes(&stuck);
115 }
116
117 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 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 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 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 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 fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
314 let targets = parse_targets(target);
315 let (processes, _) = resolve_targets_excluding_self(&targets);
316 if processes.is_empty() {
317 Err(ProcError::ProcessNotFound(target.to_string()))
318 } else {
319 Ok(processes)
320 }
321 }
322
323 fn is_stuck(&self, proc: &Process) -> bool {
325 proc.cpu_percent > 50.0
326 }
327
328 #[cfg(unix)]
330 fn attempt_unstick(&self, proc: &Process) -> Outcome {
331 if self.target.is_some() && !self.is_stuck(proc) {
333 return Outcome::NotStuck;
334 }
335
336 let pid = Pid::from_raw(proc.pid as i32);
337
338 let _ = kill(pid, Signal::SIGCONT);
340 std::thread::sleep(Duration::from_secs(1));
341
342 if self.check_recovered(proc) {
343 return Outcome::Recovered;
344 }
345
346 if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
348 return Outcome::Terminated;
349 }
350 std::thread::sleep(Duration::from_secs(3));
351
352 if !proc.is_running() {
353 return Outcome::Terminated;
354 }
355 if self.check_recovered(proc) {
356 return Outcome::Recovered;
357 }
358
359 if !self.force {
361 return Outcome::StillStuck;
362 }
363
364 if proc.terminate().is_err() && !proc.is_running() {
366 return Outcome::Terminated;
367 }
368 std::thread::sleep(Duration::from_secs(5));
369
370 if !proc.is_running() {
371 return Outcome::Terminated;
372 }
373
374 match proc.kill() {
376 Ok(()) => Outcome::Terminated,
377 Err(e) => {
378 if !proc.is_running() {
379 Outcome::Terminated
380 } else {
381 Outcome::Failed(e.to_string())
382 }
383 }
384 }
385 }
386
387 #[cfg(not(unix))]
388 fn attempt_unstick(&self, proc: &Process) -> Outcome {
389 if self.target.is_some() && !self.is_stuck(proc) {
391 return Outcome::NotStuck;
392 }
393
394 if !self.force {
396 return Outcome::StillStuck;
397 }
398
399 if proc.terminate().is_ok() {
400 std::thread::sleep(Duration::from_secs(3));
401 if !proc.is_running() {
402 return Outcome::Terminated;
403 }
404 }
405
406 match proc.kill() {
407 Ok(()) => Outcome::Terminated,
408 Err(e) => Outcome::Failed(e.to_string()),
409 }
410 }
411
412 #[cfg(unix)]
414 fn check_recovered(&self, proc: &Process) -> bool {
415 if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
416 current.cpu_percent < 10.0
417 } else {
418 false
419 }
420 }
421
422 fn show_processes(&self, processes: &[Process]) {
423 let label = if self.target.is_some() {
424 "Target"
425 } else {
426 "Found stuck"
427 };
428
429 println!(
430 "\n{} {} {} process{}:\n",
431 "!".yellow().bold(),
432 label,
433 processes.len().to_string().cyan().bold(),
434 if processes.len() == 1 { "" } else { "es" }
435 );
436
437 for proc in processes {
438 let uptime = proc
439 .start_time
440 .map(|st| {
441 let now = std::time::SystemTime::now()
442 .duration_since(std::time::UNIX_EPOCH)
443 .map(|d| d.as_secs().saturating_sub(st))
444 .unwrap_or(0);
445 format_duration(now)
446 })
447 .unwrap_or_else(|| "unknown".to_string());
448
449 println!(
450 " {} {} [PID {}] - {:.1}% CPU, running for {}",
451 "→".bright_black(),
452 proc.name.white().bold(),
453 proc.pid.to_string().cyan(),
454 proc.cpu_percent,
455 uptime.yellow()
456 );
457 }
458 }
459}
460
461fn format_duration(secs: u64) -> String {
462 if secs < 60 {
463 format!("{}s", secs)
464 } else if secs < 3600 {
465 format!("{}m", secs / 60)
466 } else if secs < 86400 {
467 format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
468 } else {
469 format!("{}d {}h", secs / 86400, (secs % 86400) / 3600)
470 }
471}
472
473#[derive(Serialize)]
474struct UnstickOutput {
475 action: &'static str,
476 success: bool,
477 dry_run: bool,
478 force: bool,
479 found: usize,
480 recovered: usize,
481 not_stuck: usize,
482 still_stuck: usize,
483 terminated: usize,
484 failed: usize,
485 processes: Vec<ProcessOutcome>,
486}
487
488#[derive(Serialize)]
489struct ProcessOutcome {
490 pid: u32,
491 name: String,
492 outcome: String,
493}