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