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