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<()> {
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 resolve_target(target).map_err(|_| ProcError::ProcessNotFound(target.to_string()))
315 }
316
317 fn is_stuck(&self, proc: &Process) -> bool {
319 proc.cpu_percent > 50.0
320 }
321
322 #[cfg(unix)]
324 fn attempt_unstick(&self, proc: &Process) -> Outcome {
325 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 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 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 if !self.force {
355 return Outcome::StillStuck;
356 }
357
358 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 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 if self.target.is_some() && !self.is_stuck(proc) {
385 return Outcome::NotStuck;
386 }
387
388 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 #[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}