Skip to main content

proc_cli/commands/
stop.rs

1//! Stop command - Graceful process termination (SIGTERM)
2//!
3//! Usage:
4//!   proc stop 1234              # Stop PID 1234
5//!   proc stop :3000             # Stop process on port 3000
6//!   proc stop node              # Stop the processes named node
7//!   proc stop node --cmd        # ... or with node anywhere in their command line
8//!   proc stop 1234 5678         # Stop several targets: separate arguments
9//!   proc stop :3000 :8080 node  # Mixed targets (port + port + name)
10//!
11//! Commas still work (`proc stop :3000,:8080`) but are deprecated.
12
13#[cfg(unix)]
14use crate::core::parse_signal_name;
15use crate::core::{
16    apply_filters, apply_to_each, flatten_targets, resolve_targets_excluding_self, NameMatch,
17    Process,
18};
19use crate::error::{ProcError, Result};
20use crate::ui::Printer;
21use clap::Args;
22
23/// Stop process(es) gracefully with SIGTERM
24#[derive(Args, Debug)]
25pub struct StopCommand {
26    /// Target(s): process name, PID, or :port (separate arguments for several)
27    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
28    target: Vec<String>,
29
30    /// Skip confirmation prompt
31    #[arg(long, short = 'y')]
32    yes: bool,
33
34    /// Show what would be stopped without actually stopping
35    #[arg(long)]
36    dry_run: bool,
37
38    /// Output as JSON
39    #[arg(long, short = 'j')]
40    json: bool,
41
42    /// Show verbose output
43    #[arg(long, short = 'v')]
44    verbose: bool,
45
46    /// Timeout in seconds to wait before force kill
47    #[arg(long, short, default_value = "10")]
48    timeout: u64,
49
50    /// Filter by directory; takes the next word as the directory unless it starts with '-'
51    /// (bare --in is the current directory, --in=DIR is explicit)
52    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
53    pub in_dir: Option<String>,
54
55    /// Filter by process name
56    #[arg(long = "by", short = 'b')]
57    pub by_name: Option<String>,
58
59    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
60    #[arg(long)]
61    pub cmd: bool,
62
63    /// Initial signal to send instead of SIGTERM (e.g. HUP, USR1, INT)
64    #[arg(long, short = 'S')]
65    pub signal: Option<String>,
66}
67
68impl StopCommand {
69    /// Executes the stop command, gracefully terminating matched processes.
70    pub fn execute(&self) -> Result<()> {
71        let printer = Printer::from_flags(self.json, self.verbose);
72
73        // Validate --signal first, so a bad name fails the same way with or
74        // without --dry-run, and before the confirmation prompt
75        #[cfg(unix)]
76        let custom_signal = if let Some(ref sig_name) = self.signal {
77            Some(parse_signal_name(sig_name)?)
78        } else {
79            None
80        };
81        // Windows has no signals: say so rather than quietly sending a normal stop
82        #[cfg(not(unix))]
83        if self.signal.is_some() {
84            return Err(ProcError::InvalidInput(
85                "--signal is not supported on Windows (proc stop asks the process to exit)"
86                    .to_string(),
87            ));
88        }
89
90        // Split each value on commas and resolve to processes
91        // Use resolve_targets_excluding_self to avoid stopping ourselves
92        let targets = flatten_targets(&self.target);
93        let mode = NameMatch::from_flag(self.cmd);
94        let resolved = resolve_targets_excluding_self(&targets, mode);
95
96        // Warn about targets that weren't found
97        if let Some(warning) = resolved.warning() {
98            printer.warning(&warning);
99        }
100        // the JSON results list the targets that matched nothing
101        let printer = printer.with_not_found(&resolved.not_found);
102        let mut processes = resolved.processes;
103
104        // Apply --in and --by filters
105        apply_filters(&mut processes, &self.in_dir, &self.by_name, mode);
106
107        // Real CPU readings whenever the list is shown or returned (dry run, the
108        // confirmation prompt, or the error for a missing `--yes`); only `--yes`
109        // acts at once, without the wait
110        if self.dry_run || !self.yes {
111            Process::measure_cpu(&mut processes);
112        }
113
114        if processes.is_empty() {
115            return Err(ProcError::ProcessNotFound(self.target.join(" ")));
116        }
117
118        // Dry run: just show what would be stopped
119        if self.dry_run {
120            printer.print_dry_run("stop", &processes);
121            return Ok(());
122        }
123
124        // Confirm if not --yes
125        if !printer.ask_confirm("stop", &processes, self.yes)? {
126            return Ok(());
127        }
128
129        // Ask every process first, then wait for them together, so several
130        // processes share one --timeout instead of taking one each
131        let (signalled, mut failed) = apply_to_each(&processes, |proc| {
132            #[cfg(unix)]
133            if let Some(signal) = custom_signal {
134                return proc.send_signal(signal);
135            }
136            ask_to_end(proc)
137        });
138
139        let outlived = self.wait_for_exit(&signalled);
140
141        // Force kill whatever outlived the timeout
142        let (stopped, failed_to_kill) = apply_to_each(&signalled, |proc| {
143            if outlived.contains(&proc.pid) {
144                proc.kill_and_wait()
145            } else {
146                Ok(())
147            }
148        });
149        failed.extend(failed_to_kill);
150
151        // Output results
152        printer.print_action_result("stop", &stopped, &failed)
153    }
154
155    /// Wait up to --timeout for the processes to exit; returns the PIDs still running.
156    fn wait_for_exit(&self, processes: &[Process]) -> Vec<u32> {
157        let start = std::time::Instant::now();
158        let timeout = std::time::Duration::from_secs(self.timeout);
159        let mut running: Vec<&Process> = processes.iter().collect();
160
161        loop {
162            running.retain(|p| p.is_running());
163            if running.is_empty() || start.elapsed() >= timeout {
164                return running.iter().map(|p| p.pid).collect();
165            }
166            std::thread::sleep(std::time::Duration::from_millis(100));
167        }
168    }
169}
170
171/// Ask one process to end (SIGTERM; on Windows, `taskkill` without `/F`)
172fn ask_to_end(proc: &Process) -> Result<()> {
173    match proc.terminate() {
174        // Windows can't ask a console program to close (taskkill without /F
175        // needs a window); it still gets stopped, by the force kill after
176        // --timeout
177        Err(_) if cfg!(not(unix)) && proc.is_running() => Ok(()),
178        result => result,
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::test_support::{end, gone_record, idle_child, record_of};
186
187    #[test]
188    fn a_process_that_is_gone_cannot_be_asked_to_end() {
189        let mut child = idle_child();
190        let record = gone_record(&mut child);
191        // Not running, so the refusal is not the expected one a console
192        // program gives on Windows: it is reported
193        let result = ask_to_end(&record);
194        assert!(
195            matches!(&result, Err(ProcError::ProcessNotFound(_))),
196            "{result:?}"
197        );
198    }
199
200    #[test]
201    fn a_running_process_is_asked_and_the_answer_does_not_matter_on_windows() {
202        let mut child = idle_child();
203        let record = record_of(&child);
204        let result = ask_to_end(&record);
205        end(&mut child);
206        // Unix: SIGTERM was delivered. Windows: taskkill refuses a console
207        // program, and that is not a failure.
208        assert!(result.is_ok(), "{result:?}");
209    }
210}