Skip to main content

proc_cli/commands/
free.rs

1//! `proc free` - Free ports by killing their processes and verifying availability
2//!
3//! Examples:
4//!   proc free :3000              # Kill process on port 3000, verify port freed
5//!   proc free :3000 :8080        # Free several ports: separate arguments
6//!   proc free :3000 --yes        # Skip confirmation
7//!   proc free :3000 --wait 30    # Wait up to 30s for port to free
8//!
9//! Commas still work (`proc free :3000,:8080`) but are deprecated.
10
11use crate::core::port::PortInfo;
12use crate::core::{flatten_targets, parse_target, Process, TargetType};
13use crate::error::{ExitCode, ProcError, Result};
14use crate::ui::{must_confirm, plural, Printer};
15use clap::Args;
16use colored::*;
17use dialoguer::Confirm;
18use serde::Serialize;
19
20/// Free port(s) by killing the process and verifying the port is available
21#[derive(Args, Debug)]
22pub struct FreeCommand {
23    /// Port target(s): :port (separate arguments for several, e.g. :3000 :8080)
24    #[arg(required = true, num_args = 1.., value_parser = crate::core::validate_target_arg)]
25    pub target: Vec<String>,
26
27    /// Skip confirmation prompt
28    #[arg(long, short = 'y')]
29    pub yes: bool,
30
31    /// Show what would be freed without actually freeing
32    #[arg(long)]
33    pub dry_run: bool,
34
35    /// Output as JSON
36    #[arg(long, short = 'j')]
37    pub json: bool,
38
39    /// Show verbose output
40    #[arg(long, short = 'v')]
41    pub verbose: bool,
42
43    /// Max seconds to wait for port to become free
44    #[arg(long, default_value = "10")]
45    pub wait: u64,
46}
47
48impl FreeCommand {
49    /// Executes the free command, killing processes and verifying ports are freed.
50    pub fn execute(&self) -> Result<()> {
51        let printer = Printer::from_flags(self.json, self.verbose);
52
53        let targets = flatten_targets(&self.target);
54
55        // Validate all targets are ports
56        let mut ports: Vec<(&String, u16)> = Vec::new();
57        for target in &targets {
58            match parse_target(target) {
59                TargetType::Port(port) => ports.push((target, port)),
60                _ => {
61                    return Err(ProcError::InvalidInput(format!(
62                        "proc free only works with port targets (e.g. :3000), got '{}'. Use proc kill for processes.",
63                        target
64                    )));
65                }
66            }
67        }
68
69        // Resolve ports to processes: every process holding a port
70        let mut port_processes: Vec<(u16, Process)> = Vec::new();
71        // the ports nobody holds, as typed (":3000")
72        let mut not_found: Vec<String> = Vec::new();
73
74        for (target, port) in &ports {
75            // A port nobody listens on, or whose holders have all gone, is not found
76            let mut held = false;
77            for listener in PortInfo::holders_of(*port)? {
78                if let Some(proc) = Process::find_by_pid(listener.pid)? {
79                    port_processes.push((*port, proc));
80                    held = true;
81                }
82            }
83            if !held {
84                printer.warning(&format!("No process listening on port {}", port));
85                not_found.push((*target).clone());
86            }
87        }
88        // the JSON results list the ports that matched nothing
89        let printer = printer.with_not_found(&not_found);
90
91        if port_processes.is_empty() {
92            if not_found.is_empty() {
93                return Err(ProcError::InvalidInput(
94                    "No port targets specified".to_string(),
95                ));
96            }
97            printer.print_empty_result("free", "All specified ports are already free");
98            return Ok(());
99        }
100
101        // The ports to free, once each however many processes hold them
102        let target_ports: Vec<u16> = {
103            let mut seen = std::collections::HashSet::new();
104            port_processes
105                .iter()
106                .map(|(port, _)| *port)
107                .filter(|port| seen.insert(*port))
108                .collect()
109        };
110
111        // Deduplicate processes (multiple ports might be held by same process)
112        let mut processes: Vec<Process> = {
113            let mut seen = std::collections::HashSet::new();
114            port_processes
115                .iter()
116                .filter(|(_, p)| seen.insert(p.pid))
117                .map(|(_, p)| p.clone())
118                .collect()
119        };
120
121        // Real CPU readings whenever the list is shown or returned (dry run,
122        // the prompt, or the error for a missing `--yes`); `--yes` acts at once
123        if self.dry_run || !self.yes {
124            Process::measure_cpu(&mut processes);
125        }
126
127        if self.dry_run {
128            printer.print_dry_run_with(
129                "free",
130                &processes,
131                &format!(
132                    "Dry run: would kill {} process{} to free {} port{}",
133                    processes.len(),
134                    plural(processes.len()),
135                    target_ports.len(),
136                    if target_ports.len() == 1 { "" } else { "s" }
137                ),
138            );
139            return Ok(());
140        }
141
142        // Same rule everywhere: --yes, or ask; --json never skips it
143        if must_confirm("free", self.yes, self.json, &processes, Some(&not_found))? {
144            printer.print_confirmation("free", &processes);
145
146            let prompt = format!(
147                "Kill {} process{} to free {} port{}?",
148                processes.len(),
149                plural(processes.len()),
150                target_ports.len(),
151                if target_ports.len() == 1 { "" } else { "s" }
152            );
153
154            if !Confirm::new()
155                .with_prompt(prompt)
156                .default(false)
157                .interact()?
158            {
159                printer.warning("Aborted");
160                return Ok(());
161            }
162        }
163
164        // SIGTERM first, then SIGKILL for what is left
165        let start = std::time::Instant::now();
166        stop_all(
167            &processes,
168            std::time::Duration::from_secs(self.wait.min(5)),
169            &printer,
170            Process::terminate,
171            Process::kill,
172        );
173
174        // Poll until ports are free
175        let mut freed: Vec<u16> = Vec::new();
176        let mut still_busy: Vec<u16> = Vec::new();
177
178        // --wait covers the whole run, graceful exit included, so it is a real
179        // maximum; after a force kill the port still gets a second to be released
180        let poll_deadline = std::cmp::max(
181            start + std::time::Duration::from_secs(self.wait),
182            std::time::Instant::now() + std::time::Duration::from_secs(1),
183        );
184
185        // A port counts as free once it reads free in two looks in a row: a
186        // copy of the holder (a supervisor, a forked worker) can take it back
187        // within milliseconds of the holder's death, and a single look between
188        // the two says "free" about a port that is in use again
189        let mut was_free = false;
190        loop {
191            let mut all_free = true;
192            freed.clear();
193            still_busy.clear();
194
195            for port in &target_ports {
196                // free once nobody is left listening on it, whoever that was
197                match PortInfo::holders_of(*port) {
198                    Ok(holders) if holders.is_empty() => freed.push(*port),
199                    _ => {
200                        still_busy.push(*port);
201                        all_free = false;
202                    }
203                }
204            }
205
206            let settled = all_free && was_free;
207            was_free = all_free;
208            if settled || std::time::Instant::now() >= poll_deadline {
209                break;
210            }
211
212            std::thread::sleep(std::time::Duration::from_millis(250));
213        }
214
215        // Report results
216        if self.json {
217            let results: Vec<FreeResult> = freed
218                .iter()
219                .map(|p| FreeResult {
220                    port: *p,
221                    freed: true,
222                })
223                .chain(still_busy.iter().map(|p| FreeResult {
224                    port: *p,
225                    freed: false,
226                }))
227                .collect();
228
229            printer.print_json(&FreeOutput {
230                action: "free",
231                success: still_busy.is_empty(),
232                results,
233                not_found: &not_found,
234            });
235        } else {
236            for port in &freed {
237                println!(
238                    "{} Freed port {}",
239                    "✓".green().bold(),
240                    port.to_string().cyan().bold()
241                );
242            }
243            for port in &still_busy {
244                println!(
245                    "{} Port {} still in use (may be in TIME_WAIT)",
246                    "✗".red().bold(),
247                    port.to_string().cyan()
248                );
249            }
250        }
251
252        if still_busy.is_empty() {
253            Ok(())
254        } else {
255            Err(ProcError::reported(
256                ExitCode::GeneralError,
257                format!(
258                    "{} port{} still in use",
259                    still_busy.len(),
260                    if still_busy.len() == 1 { "" } else { "s" }
261                ),
262            ))
263        }
264    }
265}
266
267#[derive(Serialize)]
268struct FreeOutput<'a> {
269    action: &'static str,
270    success: bool,
271    results: Vec<FreeResult>,
272    /// The ports nobody was listening on, as typed
273    not_found: &'a [String],
274}
275
276#[derive(Serialize)]
277struct FreeResult {
278    port: u16,
279    freed: bool,
280}
281
282/// Ask each process to end (SIGTERM; on Windows a refused request for a process
283/// that is still running is not a failure), give them up to `grace` to do it, then
284/// force kill whatever is still running. A signal that fails is a warning, not
285/// an error: the port check afterwards says whether it mattered. Returns the
286/// PIDs a signal failed for. The signals are parameters so a test can make them
287/// fail; the command passes `Process::terminate` and `Process::kill`.
288fn stop_all(
289    processes: &[Process],
290    grace: std::time::Duration,
291    printer: &Printer,
292    terminate: impl Fn(&Process) -> Result<()>,
293    kill: impl Fn(&Process) -> Result<()>,
294) -> Vec<u32> {
295    let mut failed = Vec::new();
296    for proc in processes {
297        match terminate(proc) {
298            // Windows can't ask a console program to close (taskkill without
299            // /F needs a window); the force kill below still stops it
300            Err(_) if cfg!(not(unix)) && proc.is_running() => {}
301            Err(e) => {
302                printer.warning(&format!(
303                    "Failed to ask {} [PID {}] to stop: {}",
304                    proc.name, proc.pid, e
305                ));
306                failed.push(proc.pid);
307            }
308            Ok(()) => {}
309        }
310    }
311
312    // Wait for graceful exit
313    let start = std::time::Instant::now();
314    while start.elapsed() < grace {
315        if processes.iter().all(|p| !p.is_running()) {
316            break;
317        }
318        std::thread::sleep(std::time::Duration::from_millis(100));
319    }
320
321    // Force kill any remaining
322    for proc in processes {
323        if proc.is_running() {
324            if let Err(e) = kill(proc) {
325                printer.warning(&format!(
326                    "Failed to kill {} [PID {}]: {}",
327                    proc.name, proc.pid, e
328                ));
329                failed.push(proc.pid);
330            }
331        }
332    }
333    failed
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::error::ProcError;
340
341    /// This test's own process: it exists and is the process it says it is, so
342    /// `stop_all` sees it as running. The signals below are stand-ins that
343    /// send nothing.
344    fn this_process() -> Process {
345        Process::find_by_pid(std::process::id())
346            .unwrap()
347            .expect("this process exists")
348    }
349
350    fn refuse(_: &Process) -> Result<()> {
351        Err(ProcError::SignalError("not allowed".to_string()))
352    }
353
354    #[test]
355    fn signals_that_fail_are_reported_not_fatal() {
356        let me = this_process();
357        let printer = Printer::from_flags(true, false);
358        let failed = stop_all(
359            std::slice::from_ref(&me),
360            std::time::Duration::ZERO,
361            &printer,
362            refuse,
363            refuse,
364        );
365        // It still counts as running, so the SIGKILL is tried after the polite
366        // request; on Windows that request being refused is expected
367        let refusals = if cfg!(unix) { 2 } else { 1 };
368        assert_eq!(failed, vec![me.pid; refusals]);
369    }
370
371    #[test]
372    fn a_refusal_for_a_process_that_is_gone_is_a_warning_and_nothing_is_waited_for() {
373        let mut child = crate::test_support::idle_child();
374        let gone = crate::test_support::gone_record(&mut child);
375        let printer = Printer::from_flags(true, false);
376        let start = std::time::Instant::now();
377        // Not running, so the refusal is not the one Windows gives a console
378        // program that is still there: it is reported, on every OS. The kill
379        // is never tried, and the grace period ends at once.
380        let failed = stop_all(
381            std::slice::from_ref(&gone),
382            std::time::Duration::from_secs(30),
383            &printer,
384            refuse,
385            // were the kill tried, it would fail too and be listed twice
386            refuse,
387        );
388        assert_eq!(failed, vec![gone.pid]);
389        assert!(start.elapsed() < std::time::Duration::from_secs(10));
390    }
391
392    #[test]
393    fn signals_that_work_report_nothing() {
394        let me = this_process();
395        let printer = Printer::from_flags(true, false);
396        let failed = stop_all(
397            std::slice::from_ref(&me),
398            std::time::Duration::ZERO,
399            &printer,
400            |_| Ok(()),
401            |_| Ok(()),
402        );
403        assert!(failed.is_empty());
404    }
405}