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::out_println;
15use crate::ui::{must_confirm, plural, prompt, Printer};
16use clap::Args;
17use colored::*;
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        // Targets are required, never empty (the parser refuses "" and ","),
92        // and only ports (checked above), so with no holders every port is in
93        // `not_found`: all of them are already free
94        if port_processes.is_empty() {
95            printer.print_empty_result("free", "All specified ports are already free");
96            return Ok(());
97        }
98
99        // The ports to free, once each however many processes hold them
100        let target_ports: Vec<u16> = {
101            let mut seen = std::collections::HashSet::new();
102            port_processes
103                .iter()
104                .map(|(port, _)| *port)
105                .filter(|port| seen.insert(*port))
106                .collect()
107        };
108
109        // Deduplicate processes (multiple ports might be held by same process)
110        let mut processes: Vec<Process> = {
111            let mut seen = std::collections::HashSet::new();
112            port_processes
113                .iter()
114                .filter(|(_, p)| seen.insert(p.pid))
115                .map(|(_, p)| p.clone())
116                .collect()
117        };
118
119        // Real CPU readings whenever the list is shown or returned (dry run,
120        // the prompt, or the error for a missing `--yes`); `--yes` acts at once
121        if self.dry_run || !self.yes {
122            Process::measure_cpu(&mut processes);
123        }
124
125        if self.dry_run {
126            printer.print_dry_run_with(
127                "free",
128                &processes,
129                &format!(
130                    "Dry run: would kill {} process{} to free {} port{}",
131                    processes.len(),
132                    plural(processes.len()),
133                    target_ports.len(),
134                    if target_ports.len() == 1 { "" } else { "s" }
135                ),
136            );
137            return Ok(());
138        }
139
140        // Same rule everywhere: --yes, or ask; --json never skips it
141        if must_confirm("free", self.yes, self.json, &processes, Some(&not_found))? {
142            let question = format!(
143                "Kill {} process{} to free {} port{}?",
144                processes.len(),
145                plural(processes.len()),
146                target_ports.len(),
147                if target_ports.len() == 1 { "" } else { "s" }
148            );
149
150            if !prompt::ask("free", &processes, Some(&not_found), &question, || {
151                printer.print_confirmation("free", &processes)
152            })? {
153                printer.warning("Aborted");
154                return Ok(());
155            }
156        }
157
158        // SIGTERM first, then SIGKILL for what is left
159        let start = std::time::Instant::now();
160        stop_all(
161            &processes,
162            std::time::Duration::from_secs(self.wait.min(5)),
163            &printer,
164            Process::terminate,
165            Process::kill,
166        );
167
168        // Poll until ports are free
169        let mut freed: Vec<u16> = Vec::new();
170        let mut still_busy: Vec<u16> = Vec::new();
171
172        // --wait covers the whole run, graceful exit included, so it is a real
173        // maximum; after a force kill the port still gets a second to be released
174        let poll_deadline = std::cmp::max(
175            start + std::time::Duration::from_secs(self.wait),
176            std::time::Instant::now() + std::time::Duration::from_secs(1),
177        );
178
179        // A port counts as free once it reads free in two looks in a row: a
180        // copy of the holder (a supervisor, a forked worker) can take it back
181        // within milliseconds of the holder's death, and a single look between
182        // the two says "free" about a port that is in use again
183        let mut was_free = false;
184        loop {
185            let mut all_free = true;
186            freed.clear();
187            still_busy.clear();
188
189            for port in &target_ports {
190                // free once nobody is left listening on it, whoever that was
191                match PortInfo::holders_of(*port) {
192                    Ok(holders) if holders.is_empty() => freed.push(*port),
193                    _ => {
194                        still_busy.push(*port);
195                        all_free = false;
196                    }
197                }
198            }
199
200            let settled = all_free && was_free;
201            was_free = all_free;
202            if settled || std::time::Instant::now() >= poll_deadline {
203                break;
204            }
205
206            std::thread::sleep(std::time::Duration::from_millis(250));
207        }
208
209        // Report results
210        if self.json {
211            let results: Vec<FreeResult> = freed
212                .iter()
213                .map(|p| FreeResult {
214                    port: *p,
215                    freed: true,
216                })
217                .chain(still_busy.iter().map(|p| FreeResult {
218                    port: *p,
219                    freed: false,
220                }))
221                .collect();
222
223            printer.print_json(&FreeOutput {
224                action: "free",
225                success: still_busy.is_empty(),
226                results,
227                not_found: &not_found,
228            });
229        } else {
230            for port in &freed {
231                out_println!(
232                    "{} Freed port {}",
233                    "✓".green().bold(),
234                    port.to_string().cyan().bold()
235                );
236            }
237            for port in &still_busy {
238                out_println!(
239                    "{} Port {} still in use (may be in TIME_WAIT)",
240                    "✗".red().bold(),
241                    port.to_string().cyan()
242                );
243            }
244        }
245
246        if still_busy.is_empty() {
247            Ok(())
248        } else {
249            Err(ProcError::reported(
250                ExitCode::GeneralError,
251                format!(
252                    "{} port{} still in use",
253                    still_busy.len(),
254                    if still_busy.len() == 1 { "" } else { "s" }
255                ),
256            ))
257        }
258    }
259}
260
261#[derive(Serialize)]
262struct FreeOutput<'a> {
263    action: &'static str,
264    success: bool,
265    results: Vec<FreeResult>,
266    /// The ports nobody was listening on, as typed
267    not_found: &'a [String],
268}
269
270#[derive(Serialize)]
271struct FreeResult {
272    port: u16,
273    freed: bool,
274}
275
276/// Ask each process to end (SIGTERM; on Windows a refused request for a process
277/// that is still running is not a failure), give them up to `grace` to do it, then
278/// force kill whatever is still running. A signal that fails is a warning, not
279/// an error: the port check afterwards says whether it mattered. Returns the
280/// PIDs a signal failed for. The signals are parameters so a test can make them
281/// fail; the command passes `Process::terminate` and `Process::kill`.
282fn stop_all(
283    processes: &[Process],
284    grace: std::time::Duration,
285    printer: &Printer,
286    terminate: impl Fn(&Process) -> Result<()>,
287    kill: impl Fn(&Process) -> Result<()>,
288) -> Vec<u32> {
289    let mut failed = Vec::new();
290    for proc in processes {
291        match terminate(proc) {
292            // Windows can't ask a console program to close (taskkill without
293            // /F needs a window); the force kill below still stops it
294            Err(_) if cfg!(not(unix)) && proc.is_running() => {}
295            Err(e) => {
296                printer.warning(&format!(
297                    "Failed to ask {} [PID {}] to stop: {}",
298                    proc.name, proc.pid, e
299                ));
300                failed.push(proc.pid);
301            }
302            Ok(()) => {}
303        }
304    }
305
306    // Wait for graceful exit
307    let start = std::time::Instant::now();
308    while start.elapsed() < grace {
309        if processes.iter().all(|p| !p.is_running()) {
310            break;
311        }
312        std::thread::sleep(std::time::Duration::from_millis(100));
313    }
314
315    // Force kill any remaining
316    for proc in processes {
317        if proc.is_running() {
318            if let Err(e) = kill(proc) {
319                printer.warning(&format!(
320                    "Failed to kill {} [PID {}]: {}",
321                    proc.name, proc.pid, e
322                ));
323                failed.push(proc.pid);
324            }
325        }
326    }
327    failed
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::error::ProcError;
334
335    /// This test's own process: it exists and is the process it says it is, so
336    /// `stop_all` sees it as running. The signals below are stand-ins that
337    /// send nothing.
338    fn this_process() -> Process {
339        Process::find_by_pid(std::process::id())
340            .unwrap()
341            .expect("this process exists")
342    }
343
344    fn refuse(_: &Process) -> Result<()> {
345        Err(ProcError::SignalError("not allowed".to_string()))
346    }
347
348    #[test]
349    fn signals_that_fail_are_reported_not_fatal() {
350        let me = this_process();
351        let printer = Printer::from_flags(true, false);
352        let failed = stop_all(
353            std::slice::from_ref(&me),
354            std::time::Duration::ZERO,
355            &printer,
356            refuse,
357            refuse,
358        );
359        // It still counts as running, so the SIGKILL is tried after the polite
360        // request; on Windows that request being refused is expected
361        let refusals = if cfg!(unix) { 2 } else { 1 };
362        assert_eq!(failed, vec![me.pid; refusals]);
363    }
364
365    #[test]
366    fn a_refusal_for_a_process_that_is_gone_is_a_warning_and_nothing_is_waited_for() {
367        let mut child = crate::test_support::idle_child();
368        let gone = crate::test_support::gone_record(&mut child);
369        let printer = Printer::from_flags(true, false);
370        let start = std::time::Instant::now();
371        // Not running, so the refusal is not the one Windows gives a console
372        // program that is still there: it is reported, on every OS. The kill
373        // is never tried, and the grace period ends at once.
374        let failed = stop_all(
375            std::slice::from_ref(&gone),
376            std::time::Duration::from_secs(30),
377            &printer,
378            refuse,
379            // were the kill tried, it would fail too and be listed twice
380            refuse,
381        );
382        assert_eq!(failed, vec![gone.pid]);
383        assert!(start.elapsed() < std::time::Duration::from_secs(10));
384    }
385
386    #[test]
387    fn signals_that_work_report_nothing() {
388        let me = this_process();
389        let printer = Printer::from_flags(true, false);
390        let failed = stop_all(
391            std::slice::from_ref(&me),
392            std::time::Duration::ZERO,
393            &printer,
394            |_| Ok(()),
395            |_| Ok(()),
396        );
397        assert!(failed.is_empty());
398    }
399}