1use 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#[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 = 'v')]
57 verbose: bool,
58
59 #[arg(long, short = 'j')]
61 json: bool,
62
63 #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
65 pub in_dir: Option<String>,
66
67 #[arg(long = "by", short = 'b')]
69 pub by_name: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq)]
73enum Outcome {
74 Recovered, Terminated, StillStuck, NotStuck, Failed(String),
79}
80
81impl UnstickCommand {
82 pub fn execute(&self) -> Result<()> {
84 let printer = Printer::from_flags(self.json, self.verbose);
85
86 let mut stuck = if let Some(ref target) = self.target {
88 self.resolve_target_processes(target)?
90 } else {
91 let timeout = Duration::from_secs(self.timeout);
93 Process::find_stuck(timeout)?
94 };
95
96 apply_filters(&mut stuck, &self.in_dir, &self.by_name);
98
99 if stuck.is_empty() {
100 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 if !self.json {
126 self.show_processes(&stuck);
127 }
128
129 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 outcome: "would_attempt".to_string(),
149 })
150 .collect(),
151 });
152 } else {
153 println!(
154 "\n{} Dry run: Would attempt to unstick {} process{}",
155 "ℹ".blue().bold(),
156 stuck.len().to_string().cyan().bold(),
157 plural(stuck.len())
158 );
159 if self.force {
160 println!(" With --force: will terminate if recovery fails");
161 } else {
162 println!(" Without --force: will only attempt recovery");
163 }
164 println!();
165 }
166 return Ok(());
167 }
168
169 if !self.yes && !self.json {
171 if self.force {
172 println!(
173 "\n{} With --force: processes will be terminated if recovery fails.\n",
174 "!".yellow().bold()
175 );
176 } else {
177 println!(
178 "\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
179 "ℹ".blue().bold()
180 );
181 }
182
183 let prompt = format!("Unstick {} process{}?", stuck.len(), plural(stuck.len()));
184
185 if !Confirm::new()
186 .with_prompt(prompt)
187 .default(false)
188 .interact()?
189 {
190 printer.warning("Aborted");
191 return Ok(());
192 }
193 }
194
195 let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
197
198 for proc in &stuck {
199 if !self.json {
200 print!(
201 " {} {} [PID {}]... ",
202 "→".bright_black(),
203 proc.name.white(),
204 proc.pid.to_string().cyan()
205 );
206 }
207
208 let outcome = self.attempt_unstick(proc);
209
210 if !self.json {
211 match &outcome {
212 Outcome::Recovered => println!("{}", "recovered".green()),
213 Outcome::Terminated => println!("{}", "terminated".yellow()),
214 Outcome::StillStuck => println!("{}", "still stuck".red()),
215 Outcome::NotStuck => println!("{}", "not stuck".blue()),
216 Outcome::Failed(e) => println!("{}: {}", "failed".red(), e),
217 }
218 }
219
220 outcomes.push((proc.clone(), outcome));
221 }
222
223 let recovered = outcomes
225 .iter()
226 .filter(|(_, o)| *o == Outcome::Recovered)
227 .count();
228 let terminated = outcomes
229 .iter()
230 .filter(|(_, o)| *o == Outcome::Terminated)
231 .count();
232 let still_stuck = outcomes
233 .iter()
234 .filter(|(_, o)| *o == Outcome::StillStuck)
235 .count();
236 let not_stuck = outcomes
237 .iter()
238 .filter(|(_, o)| *o == Outcome::NotStuck)
239 .count();
240 let failed = outcomes
241 .iter()
242 .filter(|(_, o)| matches!(o, Outcome::Failed(_)))
243 .count();
244
245 if self.json {
247 printer.print_json(&UnstickOutput {
248 action: "unstick",
249 success: failed == 0 && still_stuck == 0,
250 dry_run: false,
251 force: self.force,
252 found: stuck.len(),
253 recovered,
254 not_stuck,
255 still_stuck,
256 terminated,
257 failed,
258 processes: outcomes
259 .iter()
260 .map(|(p, o)| ProcessOutcome {
261 pid: p.pid,
262 name: p.name.clone(),
263 outcome: match o {
264 Outcome::Recovered => "recovered".to_string(),
265 Outcome::Terminated => "terminated".to_string(),
266 Outcome::StillStuck => "still_stuck".to_string(),
267 Outcome::NotStuck => "not_stuck".to_string(),
268 Outcome::Failed(e) => format!("failed: {}", e),
269 },
270 })
271 .collect(),
272 });
273 } else {
274 println!();
275 if recovered > 0 {
276 println!(
277 "{} {} process{} recovered",
278 "✓".green().bold(),
279 recovered.to_string().cyan().bold(),
280 plural(recovered)
281 );
282 }
283 if not_stuck > 0 {
284 println!(
285 "{} {} process{} not stuck",
286 "ℹ".blue().bold(),
287 not_stuck.to_string().cyan().bold(),
288 if not_stuck == 1 { " was" } else { "es were" }
289 );
290 }
291 if terminated > 0 {
292 println!(
293 "{} {} process{} terminated",
294 "!".yellow().bold(),
295 terminated.to_string().cyan().bold(),
296 plural(terminated)
297 );
298 }
299 if still_stuck > 0 {
300 println!(
301 "{} {} process{} still stuck (use --force to terminate)",
302 "✗".red().bold(),
303 still_stuck.to_string().cyan().bold(),
304 plural(still_stuck)
305 );
306 }
307 if failed > 0 {
308 println!(
309 "{} {} process{} failed",
310 "✗".red().bold(),
311 failed.to_string().cyan().bold(),
312 plural(failed)
313 );
314 }
315 }
316
317 if failed > 0 || still_stuck > 0 {
318 return Err(ProcError::reported(
319 ExitCode::GeneralError,
320 format!(
321 "{} process{} still stuck, {} failed",
322 still_stuck,
323 plural(still_stuck),
324 failed
325 ),
326 ));
327 }
328
329 Ok(())
330 }
331
332 fn resolve_target_processes(&self, target: &str) -> Result<Vec<Process>> {
334 let targets = parse_targets(target);
335 let (processes, _) = resolve_targets_excluding_self(&targets);
336 if processes.is_empty() {
337 Err(ProcError::ProcessNotFound(target.to_string()))
338 } else {
339 Ok(processes)
340 }
341 }
342
343 fn is_stuck(&self, proc: &Process) -> bool {
345 proc.cpu_percent > 50.0
346 }
347
348 #[cfg(unix)]
350 fn attempt_unstick(&self, proc: &Process) -> Outcome {
351 if self.target.is_some() && !self.is_stuck(proc) {
353 return Outcome::NotStuck;
354 }
355
356 let pid = Pid::from_raw(proc.pid as i32);
357
358 let _ = kill(pid, Signal::SIGCONT);
360 std::thread::sleep(Duration::from_secs(1));
361
362 if self.check_recovered(proc) {
363 return Outcome::Recovered;
364 }
365
366 if kill(pid, Signal::SIGINT).is_err() && !proc.is_running() {
368 return Outcome::Terminated;
369 }
370 std::thread::sleep(Duration::from_secs(3));
371
372 if !proc.is_running() {
373 return Outcome::Terminated;
374 }
375 if self.check_recovered(proc) {
376 return Outcome::Recovered;
377 }
378
379 if !self.force {
381 return Outcome::StillStuck;
382 }
383
384 if proc.terminate().is_err() && !proc.is_running() {
386 return Outcome::Terminated;
387 }
388 std::thread::sleep(Duration::from_secs(5));
389
390 if !proc.is_running() {
391 return Outcome::Terminated;
392 }
393
394 match proc.kill() {
396 Ok(()) => Outcome::Terminated,
397 Err(e) => {
398 if !proc.is_running() {
399 Outcome::Terminated
400 } else {
401 Outcome::Failed(e.to_string())
402 }
403 }
404 }
405 }
406
407 #[cfg(not(unix))]
408 fn attempt_unstick(&self, proc: &Process) -> Outcome {
409 if self.target.is_some() && !self.is_stuck(proc) {
411 return Outcome::NotStuck;
412 }
413
414 if !self.force {
416 return Outcome::StillStuck;
417 }
418
419 if proc.terminate().is_ok() {
420 std::thread::sleep(Duration::from_secs(3));
421 if !proc.is_running() {
422 return Outcome::Terminated;
423 }
424 }
425
426 match proc.kill() {
427 Ok(()) => Outcome::Terminated,
428 Err(e) => Outcome::Failed(e.to_string()),
429 }
430 }
431
432 #[cfg(unix)]
434 fn check_recovered(&self, proc: &Process) -> bool {
435 if let Ok(Some(current)) = Process::find_by_pid(proc.pid) {
436 current.cpu_percent < 10.0
437 } else {
438 false
439 }
440 }
441
442 fn show_processes(&self, processes: &[Process]) {
443 let label = if self.target.is_some() {
444 "Target"
445 } else {
446 "Found stuck"
447 };
448
449 println!(
450 "\n{} {} {} process{}:\n",
451 "!".yellow().bold(),
452 label,
453 processes.len().to_string().cyan().bold(),
454 plural(processes.len())
455 );
456
457 for proc in processes {
458 let uptime = proc
459 .start_time
460 .map(|st| {
461 let now = std::time::SystemTime::now()
462 .duration_since(std::time::UNIX_EPOCH)
463 .map(|d| d.as_secs().saturating_sub(st))
464 .unwrap_or(0);
465 format_duration(now)
466 })
467 .unwrap_or_else(|| "unknown".to_string());
468
469 println!(
470 " {} {} [PID {}] - {:.1}% CPU, running for {}",
471 "→".bright_black(),
472 proc.name.white().bold(),
473 proc.pid.to_string().cyan(),
474 proc.cpu_percent,
475 uptime.yellow()
476 );
477 }
478 }
479}
480
481#[derive(Serialize)]
482struct UnstickOutput {
483 action: &'static str,
484 success: bool,
485 dry_run: bool,
486 force: bool,
487 found: usize,
488 recovered: usize,
489 not_stuck: usize,
490 still_stuck: usize,
491 terminated: usize,
492 failed: usize,
493 processes: Vec<ProcessOutcome>,
494}
495
496#[derive(Serialize)]
497struct ProcessOutcome {
498 pid: u32,
499 name: String,
500 outcome: String,
501}