Skip to main content

proc_cli/commands/
wait.rs

1//! `proc wait` - Wait for process(es) to exit
2//!
3//! Examples:
4//!   proc wait node                   # Wait until the processes named node exit
5//!   proc wait node --cmd             # ... or with node anywhere in their command line
6//!   proc wait 1234                   # Wait for PID 1234 to exit
7//!   proc wait :3000                  # Wait for whatever is on port 3000 to exit
8//!   proc wait node python            # Wait for all to exit (separate arguments)
9//!   proc wait node --timeout 3600    # Timeout after 1 hour
10//!   proc wait node --interval 10     # Status line every 10 seconds
11//!
12//! Commas still work (`proc wait node,python`) but are deprecated.
13
14use crate::core::{apply_filters, flatten_targets, resolve_targets, NameMatch, Process};
15use crate::error::{ExitCode, ProcError, Result};
16use crate::ui::{format_duration, plural, Printer};
17use clap::Args;
18use colored::*;
19use serde::Serialize;
20
21/// Wait for process(es) to exit
22#[derive(Args, Debug)]
23pub struct WaitCommand {
24    /// Target(s): process name, PID, or :port (separate arguments for several)
25    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
26    target: Vec<String>,
27
28    /// Seconds between status lines (exits are noticed within a quarter second)
29    #[arg(long, short = 'n', default_value = "5")]
30    interval: u64,
31
32    /// Timeout in seconds (0 = no timeout)
33    #[arg(long, short = 't', default_value = "0")]
34    timeout: u64,
35
36    /// Output as JSON
37    #[arg(long, short = 'j')]
38    json: bool,
39
40    /// Show verbose output
41    #[arg(long, short = 'v')]
42    verbose: bool,
43
44    /// Filter by directory; takes the next word as the directory unless it starts with '-'
45    /// (bare --in is the current directory, --in=DIR is explicit)
46    #[arg(long = "in", short = 'i', num_args = 0..=1, default_missing_value = ".")]
47    pub in_dir: Option<String>,
48
49    /// Filter by process name
50    #[arg(long = "by", short = 'b')]
51    pub by_name: Option<String>,
52
53    /// Match name targets and --by as a substring of the process name or full command line (like pkill -f)
54    #[arg(long)]
55    pub cmd: bool,
56
57    /// Don't print the periodic still-running lines; the start, each exit and the result still print
58    #[arg(long, short = 'q')]
59    quiet: bool,
60}
61
62impl WaitCommand {
63    /// Executes the wait command, blocking until all matched processes exit.
64    pub fn execute(&self) -> Result<()> {
65        let printer = Printer::from_flags(self.json, self.verbose);
66
67        // Clamp interval to minimum 1 second
68        let interval = self.interval.max(1);
69
70        // Parse and resolve targets
71        let targets = flatten_targets(&self.target);
72        let mode = NameMatch::from_flag(self.cmd);
73        let resolved = resolve_targets(&targets, mode);
74
75        if let Some(warning) = resolved.warning() {
76            printer.warning(&warning);
77        }
78        let not_found = resolved.not_found;
79        let mut processes = resolved.processes;
80
81        // Apply --in and --by filters
82        apply_filters(&mut processes, &self.in_dir, &self.by_name, mode);
83
84        if processes.is_empty() {
85            return Err(ProcError::ProcessNotFound(self.target.join(" ")));
86        }
87
88        // Real CPU readings for the verbose list, the only view that shows CPU;
89        // measured before the clock starts, so it doesn't count against --timeout
90        if self.verbose && !self.json {
91            Process::measure_cpu(&mut processes);
92        }
93
94        let initial_count = processes.len();
95        let start = std::time::Instant::now();
96
97        // Print initial status
98        if !self.json {
99            println!(
100                "{} Waiting for {} process{} to exit...",
101                "~".cyan().bold(),
102                initial_count.to_string().cyan().bold(),
103                plural(initial_count)
104            );
105            if self.verbose {
106                for proc in &processes {
107                    println!(
108                        "  {} {} [PID {}] - {:.1}% CPU, {}",
109                        "->".bright_black(),
110                        proc.name.white().bold(),
111                        proc.pid.to_string().cyan(),
112                        proc.cpu_percent,
113                        crate::ui::format_memory(proc.memory_mb)
114                    );
115                }
116            }
117        }
118
119        // Track processes: (process, exited_after_seconds)
120        let mut tracking: Vec<(Process, Option<u64>)> =
121            processes.into_iter().map(|p| (p, None)).collect();
122
123        // Exits are checked every quarter second; --interval only sets how often
124        // the status line is printed. The timeout is checked after the exits, so a
125        // process that exited at the last moment is never reported as running.
126        let tick = std::time::Duration::from_millis(250);
127        let status_every = std::time::Duration::from_secs(interval);
128        let deadline =
129            (self.timeout > 0).then(|| start + std::time::Duration::from_secs(self.timeout));
130        let mut next_status = start + status_every;
131
132        loop {
133            let now = std::time::Instant::now();
134            let nap = match deadline {
135                Some(d) => tick.min(d.saturating_duration_since(now)),
136                None => tick,
137            };
138            std::thread::sleep(nap);
139
140            let elapsed = start.elapsed().as_secs();
141
142            // Check which processes have exited
143            for (proc, exited_at) in tracking.iter_mut() {
144                if exited_at.is_none() && !proc.is_running() {
145                    *exited_at = Some(elapsed);
146                    if !self.json {
147                        println!(
148                            "{} {} [PID {}] exited after {}",
149                            "✓".green().bold(),
150                            proc.name.white(),
151                            proc.pid.to_string().cyan(),
152                            format_duration(elapsed)
153                        );
154                    }
155                }
156            }
157
158            let still_running_count = tracking.iter().filter(|(_, t)| t.is_none()).count();
159
160            if still_running_count == 0 {
161                break;
162            }
163
164            // Check timeout
165            if deadline.is_some_and(|d| std::time::Instant::now() >= d) {
166                // Mark still-running processes
167                let exited: Vec<ExitedProcess> = tracking
168                    .iter()
169                    .filter(|(_, t)| t.is_some())
170                    .map(|(p, t)| ExitedProcess {
171                        pid: p.pid,
172                        name: p.name.clone(),
173                        exited_after_seconds: t.unwrap(),
174                    })
175                    .collect();
176                let still_running: Vec<RunningProcess> = tracking
177                    .iter()
178                    .filter(|(_, t)| t.is_none())
179                    .map(|(p, _)| RunningProcess {
180                        pid: p.pid,
181                        name: p.name.clone(),
182                    })
183                    .collect();
184
185                let names: Vec<String> = still_running
186                    .iter()
187                    .map(|p| format!("{} [{}]", p.name, p.pid))
188                    .collect();
189                let message = format!(
190                    "after {} — {} still running: {}",
191                    format_duration(elapsed),
192                    still_running.len(),
193                    names.join(", ")
194                );
195
196                if self.json {
197                    printer.print_json(&WaitOutput {
198                        action: "wait",
199                        success: false,
200                        timed_out: true,
201                        elapsed_seconds: elapsed,
202                        elapsed_human: format_duration(elapsed),
203                        target: self.target.join(" "),
204                        initial_count,
205                        not_found,
206                        exited,
207                        still_running,
208                    });
209                    // Same exit code as the human-mode Timeout error
210                    return Err(ProcError::reported(
211                        ExitCode::GeneralError,
212                        format!("Operation timed out: {}", message),
213                    ));
214                }
215
216                return Err(ProcError::Timeout(message));
217            }
218
219            // Print periodic status every --interval (unless quiet or json)
220            if std::time::Instant::now() < next_status {
221                continue;
222            }
223            next_status += status_every;
224            if !self.quiet && !self.json {
225                let names: Vec<String> = tracking
226                    .iter()
227                    .filter(|(_, t)| t.is_none())
228                    .map(|(p, _)| format!("{} [{}]", p.name, p.pid))
229                    .collect();
230                let exited_count = initial_count - still_running_count;
231                let exited_note = if exited_count > 0 {
232                    format!(" ({} exited)", exited_count)
233                } else {
234                    String::new()
235                };
236                println!(
237                    "{} {} elapsed — {} still running: {}{}",
238                    "~".cyan(),
239                    format_duration(elapsed),
240                    still_running_count,
241                    names.join(", "),
242                    exited_note.bright_black()
243                );
244            }
245        }
246
247        let elapsed = start.elapsed().as_secs();
248
249        // Final output
250        if self.json {
251            let exited: Vec<ExitedProcess> = tracking
252                .iter()
253                .map(|(p, t)| ExitedProcess {
254                    pid: p.pid,
255                    name: p.name.clone(),
256                    exited_after_seconds: t.unwrap_or(elapsed),
257                })
258                .collect();
259            printer.print_json(&WaitOutput {
260                action: "wait",
261                success: true,
262                timed_out: false,
263                elapsed_seconds: elapsed,
264                elapsed_human: format_duration(elapsed),
265                target: self.target.join(" "),
266                initial_count,
267                not_found,
268                exited,
269                still_running: vec![],
270            });
271        } else {
272            println!(
273                "{} All {} process{} exited after {}",
274                "✓".green().bold(),
275                initial_count.to_string().cyan().bold(),
276                plural(initial_count),
277                format_duration(elapsed)
278            );
279        }
280
281        Ok(())
282    }
283}
284
285#[derive(Serialize)]
286struct WaitOutput {
287    action: &'static str,
288    success: bool,
289    timed_out: bool,
290    elapsed_seconds: u64,
291    elapsed_human: String,
292    target: String,
293    initial_count: usize,
294    /// The targets that matched nothing
295    not_found: Vec<String>,
296    exited: Vec<ExitedProcess>,
297    still_running: Vec<RunningProcess>,
298}
299
300#[derive(Serialize, Clone)]
301struct ExitedProcess {
302    pid: u32,
303    name: String,
304    exited_after_seconds: u64,
305}
306
307#[derive(Serialize, Clone)]
308struct RunningProcess {
309    pid: u32,
310    name: String,
311}