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