1use 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#[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<()> {
71 let format = if self.json {
72 OutputFormat::Json
73 } else {
74 OutputFormat::Human
75 };
76 let printer = Printer::new(format, false);
77
78 let stuck = if let Some(ref target) = self.target {
80 self.resolve_target_processes(target)?
82 } else {
83 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 if !self.json {
113 self.show_processes(&stuck);
114 }
115
116 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 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 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 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 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 fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
313 resolve_target(target).map_err(|_| ProcError::ProcessNotFound(target.to_string()))
314 }
315
316 fn is_stuck(&self, proc: &Process) -> bool {
318 proc.cpu_percent > 50.0
319 }
320
321 #[cfg(unix)]
323 fn attempt_unstick(&self, proc: &Process) -> Outcome {
324 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 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 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 if !self.force {
354 return Outcome::StillStuck;
355 }
356
357 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 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 if self.target.is_some() && !self.is_stuck(proc) {
384 return Outcome::NotStuck;
385 }
386
387 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 #[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}