use crate::core::{
apply_filters, flatten_targets, resolve_targets_excluding_self, NameMatch, Process, Resolved,
};
use crate::error::{ExitCode, ProcError, Result};
use crate::ui::{format_duration, must_confirm, plural, Printer};
use clap::Args;
use colored::*;
use dialoguer::Confirm;
use serde::Serialize;
use std::time::Duration;
#[cfg(unix)]
use nix::sys::signal::Signal;
#[derive(Args, Debug)]
pub struct UnstickCommand {
#[arg(num_args = 0.., value_parser = crate::core::validate_target_arg)]
target: Vec<String>,
#[arg(long, short, default_value = "300")]
timeout: u64,
#[arg(long, short = 'f')]
force: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, short = 'v')]
verbose: bool,
#[arg(long, short = 'j')]
json: bool,
#[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
pub in_dir: Option<String>,
#[arg(long = "by", short = 'b')]
pub by_name: Option<String>,
#[arg(long)]
pub cmd: bool,
}
#[derive(Debug, Clone, PartialEq)]
enum Outcome {
Recovered, Terminated, StillStuck, NotStuck, Failed(String),
}
impl UnstickCommand {
pub fn execute(&self) -> Result<()> {
let printer = Printer::from_flags(self.json, self.verbose);
let (mut stuck, not_found) = if !self.target.is_empty() {
let resolved = self.resolve_target_processes(&self.target)?;
if let Some(warning) = resolved.warning() {
printer.warning(&warning);
}
(resolved.processes, resolved.not_found)
} else {
let timeout = Duration::from_secs(self.timeout);
(Process::find_stuck(timeout)?, Vec::new())
};
apply_filters(
&mut stuck,
&self.in_dir,
&self.by_name,
NameMatch::from_flag(self.cmd),
);
if stuck.is_empty() {
if !self.target.is_empty() {
return Err(ProcError::ProcessNotFound(self.target.join(" ")));
}
if self.json {
printer.print_json(&UnstickOutput {
action: "unstick",
success: true,
dry_run: self.dry_run,
force: self.force,
found: 0,
recovered: 0,
not_stuck: 0,
still_stuck: 0,
terminated: 0,
failed: 0,
not_found: ¬_found,
processes: Vec::new(),
});
} else {
printer.success("No stuck processes found");
}
return Ok(());
}
if self.dry_run || !self.yes {
Process::measure_cpu(&mut stuck);
}
if !self.json {
self.show_processes(&stuck);
}
if self.dry_run {
if self.json {
printer.print_json(&UnstickOutput {
action: "unstick",
success: true,
dry_run: true,
force: self.force,
found: stuck.len(),
recovered: 0,
not_stuck: 0,
still_stuck: 0,
terminated: 0,
failed: 0,
not_found: ¬_found,
processes: stuck
.iter()
.map(|p| ProcessOutcome {
pid: p.pid,
name: p.name.clone(),
uptime_secs: p.uptime_secs,
outcome: "would_attempt".to_string(),
})
.collect(),
});
} else {
println!(
"\n{} Dry run: Would attempt to unstick {} process{}",
"ℹ".blue().bold(),
stuck.len().to_string().cyan().bold(),
plural(stuck.len())
);
if self.force {
println!(" With --force: will terminate if recovery fails");
} else {
println!(" Without --force: will only attempt recovery");
}
println!();
}
return Ok(());
}
if must_confirm("unstick", self.yes, self.json, &stuck, Some(¬_found))? {
if self.force {
println!(
"\n{} With --force: processes will be terminated if recovery fails.\n",
"!".yellow().bold()
);
} else {
println!(
"\n{} Will attempt recovery only. Use --force to terminate if needed.\n",
"ℹ".blue().bold()
);
}
let prompt = format!("Unstick {} process{}?", stuck.len(), plural(stuck.len()));
if !Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()?
{
printer.warning("Aborted");
return Ok(());
}
}
let mut outcomes: Vec<(Process, Outcome)> = Vec::new();
for proc in &stuck {
if !self.json {
print!(
" {} {} [PID {}]... ",
"→".bright_black(),
proc.name.white(),
proc.pid.to_string().cyan()
);
}
let outcome = self.attempt_unstick(proc);
if !self.json {
println!("{}", outcome_text(&outcome));
}
outcomes.push((proc.clone(), outcome));
}
let recovered = outcomes
.iter()
.filter(|(_, o)| *o == Outcome::Recovered)
.count();
let terminated = outcomes
.iter()
.filter(|(_, o)| *o == Outcome::Terminated)
.count();
let still_stuck = outcomes
.iter()
.filter(|(_, o)| *o == Outcome::StillStuck)
.count();
let not_stuck = outcomes
.iter()
.filter(|(_, o)| *o == Outcome::NotStuck)
.count();
let failed = outcomes
.iter()
.filter(|(_, o)| matches!(o, Outcome::Failed(_)))
.count();
if self.json {
printer.print_json(&UnstickOutput {
action: "unstick",
success: failed == 0 && still_stuck == 0,
dry_run: false,
force: self.force,
found: stuck.len(),
recovered,
not_stuck,
still_stuck,
terminated,
failed,
not_found: ¬_found,
processes: outcomes
.iter()
.map(|(p, o)| ProcessOutcome {
pid: p.pid,
name: p.name.clone(),
uptime_secs: p.uptime_secs,
outcome: outcome_label(o),
})
.collect(),
});
} else {
println!();
for line in summary_lines(recovered, not_stuck, terminated, still_stuck, failed) {
println!("{line}");
}
}
if failed > 0 || still_stuck > 0 {
return Err(ProcError::reported(
ExitCode::GeneralError,
format!(
"{} process{} still stuck, {} failed",
still_stuck,
plural(still_stuck),
failed
),
));
}
Ok(())
}
fn resolve_target_processes(&self, typed: &[String]) -> Result<Resolved> {
let targets = flatten_targets(typed);
let resolved = resolve_targets_excluding_self(&targets, NameMatch::from_flag(self.cmd));
if resolved.processes.is_empty() {
Err(ProcError::ProcessNotFound(typed.join(" ")))
} else {
Ok(resolved)
}
}
fn is_stuck(&self, proc: &Process) -> bool {
proc.looks_stuck_now()
}
fn attempt_unstick(&self, proc: &Process) -> Outcome {
if !self.target.is_empty() && !self.is_stuck(proc) {
return Outcome::NotStuck;
}
#[cfg(unix)]
return recover(proc, self.force);
#[cfg(not(unix))]
return recover_by_termination(proc, self.force);
}
fn show_processes(&self, processes: &[Process]) {
let label = if !self.target.is_empty() {
"Target"
} else {
"Found stuck"
};
println!(
"\n{} {} {} process{}:\n",
"!".yellow().bold(),
label,
processes.len().to_string().cyan().bold(),
plural(processes.len())
);
for proc in processes {
let uptime = proc
.start_time
.map(|st| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().saturating_sub(st))
.unwrap_or(0);
format_duration(now)
})
.unwrap_or_else(|| "unknown".to_string());
println!(
" {} {} [PID {}] - {:.1}% CPU, running for {}",
"→".bright_black(),
proc.name.white().bold(),
proc.pid.to_string().cyan(),
proc.cpu_percent,
uptime.yellow()
);
}
}
}
trait Steps {
fn is_running(&self) -> bool;
fn pause(&self, secs: u64);
fn terminate(&self) -> Result<()>;
fn kill(&self) -> Result<()>;
#[cfg(unix)]
fn wake(&self);
#[cfg(unix)]
fn interrupt(&self) -> Result<()>;
#[cfg(unix)]
fn calmed_down(&self) -> bool;
}
impl Steps for Process {
fn is_running(&self) -> bool {
Process::is_running(self)
}
fn pause(&self, secs: u64) {
std::thread::sleep(Duration::from_secs(secs));
}
fn terminate(&self) -> Result<()> {
Process::terminate(self)
}
fn kill(&self) -> Result<()> {
Process::kill(self)
}
#[cfg(unix)]
fn wake(&self) {
let _ = self.send_signal(Signal::SIGCONT);
}
#[cfg(unix)]
fn interrupt(&self) -> Result<()> {
self.send_signal(Signal::SIGINT)
}
#[cfg(unix)]
fn calmed_down(&self) -> bool {
self.cpu_over(Duration::from_secs(1))
.is_some_and(|cpu| cpu < 10.0)
}
}
fn after_kill(killed: Result<()>, still_running: bool) -> Outcome {
match killed {
Ok(()) => Outcome::Terminated,
Err(_) if !still_running => Outcome::Terminated,
Err(e) => Outcome::Failed(e.to_string()),
}
}
#[cfg(unix)]
fn recover(steps: &impl Steps, force: bool) -> Outcome {
steps.wake();
if steps.calmed_down() {
return Outcome::Recovered;
}
if steps.interrupt().is_err() && !steps.is_running() {
return Outcome::Terminated;
}
steps.pause(3);
if !steps.is_running() {
return Outcome::Terminated;
}
if steps.calmed_down() {
return Outcome::Recovered;
}
if !force {
return Outcome::StillStuck;
}
if steps.terminate().is_err() && !steps.is_running() {
return Outcome::Terminated;
}
steps.pause(5);
if !steps.is_running() {
return Outcome::Terminated;
}
after_kill(steps.kill(), steps.is_running())
}
#[cfg(any(not(unix), test))]
fn recover_by_termination(steps: &impl Steps, force: bool) -> Outcome {
if !force {
return Outcome::StillStuck;
}
if steps.terminate().is_ok() {
steps.pause(3);
if !steps.is_running() {
return Outcome::Terminated;
}
}
after_kill(steps.kill(), steps.is_running())
}
fn outcome_text(outcome: &Outcome) -> String {
match outcome {
Outcome::Recovered => "recovered".green().to_string(),
Outcome::Terminated => "terminated".yellow().to_string(),
Outcome::StillStuck => "still stuck".red().to_string(),
Outcome::NotStuck => "not stuck".blue().to_string(),
Outcome::Failed(e) => format!("{}: {}", "failed".red(), e),
}
}
fn outcome_label(outcome: &Outcome) -> String {
match outcome {
Outcome::Recovered => "recovered".to_string(),
Outcome::Terminated => "terminated".to_string(),
Outcome::StillStuck => "still_stuck".to_string(),
Outcome::NotStuck => "not_stuck".to_string(),
Outcome::Failed(e) => format!("failed: {}", e),
}
}
fn summary_lines(
recovered: usize,
not_stuck: usize,
terminated: usize,
still_stuck: usize,
failed: usize,
) -> Vec<String> {
let mut lines = Vec::new();
if recovered > 0 {
lines.push(format!(
"{} {} process{} recovered",
"✓".green().bold(),
recovered.to_string().cyan().bold(),
plural(recovered)
));
}
if not_stuck > 0 {
lines.push(format!(
"{} {} process{} not stuck",
"ℹ".blue().bold(),
not_stuck.to_string().cyan().bold(),
if not_stuck == 1 { " was" } else { "es were" }
));
}
if terminated > 0 {
lines.push(format!(
"{} {} process{} terminated",
"!".yellow().bold(),
terminated.to_string().cyan().bold(),
plural(terminated)
));
}
if still_stuck > 0 {
lines.push(format!(
"{} {} process{} still stuck (use --force to terminate)",
"✗".red().bold(),
still_stuck.to_string().cyan().bold(),
plural(still_stuck)
));
}
if failed > 0 {
lines.push(format!(
"{} {} process{} failed",
"✗".red().bold(),
failed.to_string().cyan().bold(),
plural(failed)
));
}
lines
}
#[derive(Serialize)]
struct UnstickOutput<'a> {
action: &'static str,
success: bool,
dry_run: bool,
force: bool,
found: usize,
recovered: usize,
not_stuck: usize,
still_stuck: usize,
terminated: usize,
failed: usize,
not_found: &'a [String],
processes: Vec<ProcessOutcome>,
}
#[derive(Serialize)]
struct ProcessOutcome {
pid: u32,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
uptime_secs: Option<u64>,
outcome: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::VecDeque;
#[derive(Default)]
struct Fake {
running: RefCell<VecDeque<bool>>,
#[cfg(unix)]
calm: RefCell<VecDeque<bool>>,
#[cfg(unix)]
interrupt_fails: bool,
terminate_fails: bool,
kill_fails: bool,
calls: RefCell<Vec<String>>,
}
impl Fake {
fn running(answers: &[bool]) -> Self {
Fake {
running: RefCell::new(answers.iter().copied().collect()),
..Fake::default()
}
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
fn note(&self, what: &str) {
self.calls.borrow_mut().push(what.to_string());
}
fn result(&self, fails: bool) -> Result<()> {
if fails {
Err(ProcError::SignalError("denied".to_string()))
} else {
Ok(())
}
}
}
impl Steps for Fake {
fn is_running(&self) -> bool {
let mut answers = self.running.borrow_mut();
if answers.len() > 1 {
answers.pop_front().unwrap()
} else {
answers.front().copied().unwrap_or(true)
}
}
fn pause(&self, secs: u64) {
self.note(&format!("pause {secs}"));
}
fn terminate(&self) -> Result<()> {
self.note("terminate");
self.result(self.terminate_fails)
}
fn kill(&self) -> Result<()> {
self.note("kill");
self.result(self.kill_fails)
}
#[cfg(unix)]
fn wake(&self) {
self.note("wake");
}
#[cfg(unix)]
fn interrupt(&self) -> Result<()> {
self.note("interrupt");
self.result(self.interrupt_fails)
}
#[cfg(unix)]
fn calmed_down(&self) -> bool {
self.note("calmed_down");
self.calm.borrow_mut().pop_front().unwrap_or(false)
}
}
fn plain(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn colour_codes_are_stripped_from_text() {
assert_eq!(plain("\x1b[1;31mred\x1b[0m and plain"), "red and plain");
}
#[cfg(unix)]
#[test]
fn a_process_that_calms_after_sigcont_is_recovered_without_a_signal() {
let fake = Fake {
calm: RefCell::new([true].into()),
..Fake::running(&[true])
};
assert_eq!(recover(&fake, true), Outcome::Recovered);
assert_eq!(fake.calls(), ["wake", "calmed_down"]);
}
#[cfg(unix)]
#[test]
fn a_process_gone_after_a_failed_sigint_is_terminated() {
let fake = Fake {
interrupt_fails: true,
..Fake::running(&[false])
};
assert_eq!(recover(&fake, false), Outcome::Terminated);
assert_eq!(fake.calls(), ["wake", "calmed_down", "interrupt"]);
}
#[cfg(unix)]
#[test]
fn a_process_that_exits_after_sigint_is_terminated() {
let fake = Fake::running(&[false]);
assert_eq!(recover(&fake, false), Outcome::Terminated);
assert_eq!(
fake.calls(),
["wake", "calmed_down", "interrupt", "pause 3"]
);
}
#[cfg(unix)]
#[test]
fn a_process_that_calms_after_sigint_is_recovered() {
let fake = Fake {
calm: RefCell::new([false, true].into()),
..Fake::running(&[true])
};
assert_eq!(recover(&fake, false), Outcome::Recovered);
}
#[cfg(unix)]
#[test]
fn without_force_a_process_that_stays_busy_is_still_stuck() {
let fake = Fake::running(&[true]);
assert_eq!(recover(&fake, false), Outcome::StillStuck);
assert!(!fake.calls().contains(&"terminate".to_string()));
}
#[cfg(unix)]
#[test]
fn force_escalates_from_sigterm_to_sigkill() {
let fake = Fake::running(&[true]);
assert_eq!(recover(&fake, true), Outcome::Terminated);
assert_eq!(&fake.calls()[5..], ["terminate", "pause 5", "kill"]);
}
#[cfg(unix)]
#[test]
fn force_stops_at_sigterm_when_that_ends_the_process() {
let fake = Fake::running(&[true, false]);
assert_eq!(recover(&fake, true), Outcome::Terminated);
assert!(!fake.calls().contains(&"kill".to_string()));
}
#[cfg(unix)]
#[test]
fn force_with_a_failed_sigterm_on_a_gone_process_is_terminated() {
let fake = Fake {
terminate_fails: true,
..Fake::running(&[true, false])
};
assert_eq!(recover(&fake, true), Outcome::Terminated);
assert!(!fake.calls().contains(&"pause 5".to_string()));
}
#[cfg(unix)]
#[test]
fn a_kill_that_fails_on_a_process_still_running_is_failed() {
let fake = Fake {
kill_fails: true,
..Fake::running(&[true])
};
assert_eq!(
recover(&fake, true),
Outcome::Failed("Signal failed: denied".to_string())
);
}
#[test]
fn a_kill_that_fails_on_a_process_already_gone_is_terminated() {
assert_eq!(
after_kill(Err(ProcError::ProcessNotFound("1".into())), false),
Outcome::Terminated
);
assert_eq!(after_kill(Ok(()), true), Outcome::Terminated);
}
#[test]
fn termination_only_recovery_does_nothing_without_force() {
let fake = Fake::running(&[true]);
assert_eq!(recover_by_termination(&fake, false), Outcome::StillStuck);
assert!(fake.calls().is_empty());
}
#[test]
fn termination_only_recovery_with_force_stops_when_the_request_works() {
let fake = Fake::running(&[false]);
assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
assert_eq!(fake.calls(), ["terminate", "pause 3"]);
}
#[test]
fn termination_only_recovery_kills_what_ignores_the_request() {
let fake = Fake::running(&[true]);
assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
assert_eq!(fake.calls(), ["terminate", "pause 3", "kill"]);
let fake = Fake {
terminate_fails: true,
..Fake::running(&[true])
};
assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
assert_eq!(fake.calls(), ["terminate", "kill"]);
let fake = Fake {
kill_fails: true,
..Fake::running(&[true])
};
assert_eq!(
recover_by_termination(&fake, true),
Outcome::Failed("Signal failed: denied".to_string())
);
}
#[test]
fn a_process_that_outlasts_the_request_is_killed_and_seen_gone() {
let fake = Fake::running(&[true, false]);
assert_eq!(recover_by_termination(&fake, true), Outcome::Terminated);
assert_eq!(fake.calls(), ["terminate", "pause 3", "kill"]);
}
#[test]
fn the_steps_act_on_the_real_process() {
let mut child = crate::test_support::idle_child();
let record = crate::test_support::record_of(&child);
assert!(Steps::is_running(&record));
Steps::pause(&record, 0);
Steps::kill(&record).unwrap();
let _ = child.wait();
assert!(!Steps::is_running(&record));
crate::test_support::end(&mut child);
}
#[test]
fn each_outcome_has_its_words_in_text_and_json() {
let cases = [
(Outcome::Recovered, "recovered", "recovered"),
(Outcome::Terminated, "terminated", "terminated"),
(Outcome::StillStuck, "still stuck", "still_stuck"),
(Outcome::NotStuck, "not stuck", "not_stuck"),
(
Outcome::Failed("denied".to_string()),
"failed: denied",
"failed: denied",
),
];
for (outcome, text, label) in cases {
assert_eq!(plain(&outcome_text(&outcome)), text);
assert_eq!(outcome_label(&outcome), label);
}
}
#[test]
fn the_summary_has_a_line_for_each_kind_that_occurred() {
let all: Vec<String> = summary_lines(2, 1, 3, 1, 2)
.iter()
.map(|l| plain(l))
.collect();
assert_eq!(
all,
[
"✓ 2 processes recovered",
"ℹ 1 process was not stuck",
"! 3 processes terminated",
"✗ 1 process still stuck (use --force to terminate)",
"✗ 2 processes failed",
]
);
let some: Vec<String> = summary_lines(0, 2, 0, 0, 1)
.iter()
.map(|l| plain(l))
.collect();
assert_eq!(some, ["ℹ 2 processes were not stuck", "✗ 1 process failed"]);
assert!(summary_lines(0, 0, 0, 0, 0).is_empty());
}
}