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 multiple ports
6//!   proc free :3000 --yes        # Skip confirmation
7//!   proc free :3000 --wait 30    # Wait up to 30s for port to free
8
9use crate::core::port::PortInfo;
10use crate::core::{parse_target, parse_targets, Process, TargetType};
11use crate::error::{ExitCode, ProcError, Result};
12use crate::ui::{plural, Printer};
13use clap::Args;
14use colored::*;
15use dialoguer::Confirm;
16use serde::Serialize;
17
18/// Free port(s) by killing the process and verifying the port is available
19#[derive(Args, Debug)]
20pub struct FreeCommand {
21    /// Port target(s): :port (comma-separated for multiple, e.g. :3000,:8080)
22    #[arg(required = true)]
23    pub target: String,
24
25    /// Skip confirmation prompt
26    #[arg(long, short = 'y')]
27    pub yes: bool,
28
29    /// Show what would be freed without actually freeing
30    #[arg(long)]
31    pub dry_run: bool,
32
33    /// Output as JSON
34    #[arg(long, short = 'j')]
35    pub json: bool,
36
37    /// Show verbose output
38    #[arg(long, short = 'v')]
39    pub verbose: bool,
40
41    /// Max seconds to wait for port to become free
42    #[arg(long, default_value = "10")]
43    pub wait: u64,
44}
45
46impl FreeCommand {
47    /// Executes the free command, killing processes and verifying ports are freed.
48    pub fn execute(&self) -> Result<()> {
49        let printer = Printer::from_flags(self.json, self.verbose);
50
51        let targets = parse_targets(&self.target);
52
53        // Validate all targets are ports
54        let mut ports: Vec<u16> = Vec::new();
55        for target in &targets {
56            match parse_target(target) {
57                TargetType::Port(port) => ports.push(port),
58                _ => {
59                    return Err(ProcError::InvalidInput(format!(
60                        "proc free only works with port targets (e.g. :3000), got '{}'. Use proc kill for processes.",
61                        target
62                    )));
63                }
64            }
65        }
66
67        // Resolve ports to processes
68        let mut port_processes: Vec<(u16, Process)> = Vec::new();
69        let mut not_found: Vec<u16> = Vec::new();
70
71        for port in &ports {
72            match PortInfo::find_by_port(*port)? {
73                Some(port_info) => match Process::find_by_pid(port_info.pid)? {
74                    Some(proc) => port_processes.push((*port, proc)),
75                    None => not_found.push(*port),
76                },
77                None => not_found.push(*port),
78            }
79        }
80
81        for port in &not_found {
82            printer.warning(&format!("No process listening on port {}", port));
83        }
84
85        if port_processes.is_empty() {
86            if not_found.is_empty() {
87                return Err(ProcError::InvalidInput(
88                    "No port targets specified".to_string(),
89                ));
90            }
91            printer.print_empty_result("free", "All specified ports are already free");
92            return Ok(());
93        }
94
95        // Deduplicate processes (multiple ports might be held by same process)
96        let processes: Vec<Process> = {
97            let mut seen = std::collections::HashSet::new();
98            port_processes
99                .iter()
100                .filter(|(_, p)| seen.insert(p.pid))
101                .map(|(_, p)| p.clone())
102                .collect()
103        };
104
105        if self.dry_run {
106            printer.print_dry_run_with(
107                "free",
108                &processes,
109                &format!(
110                    "Dry run: would kill {} process{} to free {} port{}",
111                    processes.len(),
112                    plural(processes.len()),
113                    port_processes.len(),
114                    if port_processes.len() == 1 { "" } else { "s" }
115                ),
116            );
117            return Ok(());
118        }
119
120        if !self.yes && !self.json {
121            printer.print_confirmation("free", &processes);
122
123            let prompt = format!(
124                "Kill {} process{} to free {} port{}?",
125                processes.len(),
126                plural(processes.len()),
127                port_processes.len(),
128                if port_processes.len() == 1 { "" } else { "s" }
129            );
130
131            if !Confirm::new()
132                .with_prompt(prompt)
133                .default(false)
134                .interact()?
135            {
136                printer.warning("Aborted");
137                return Ok(());
138            }
139        }
140
141        // Terminate processes (SIGTERM first)
142        let mut kill_failed = Vec::new();
143        for proc in &processes {
144            if let Err(e) = proc.terminate() {
145                printer.warning(&format!(
146                    "Failed to send SIGTERM to {} [PID {}]: {}",
147                    proc.name, proc.pid, e
148                ));
149                kill_failed.push(proc.pid);
150            }
151        }
152
153        // Wait for graceful exit
154        let start = std::time::Instant::now();
155        let timeout = std::time::Duration::from_secs(self.wait.min(5));
156        while start.elapsed() < timeout {
157            if processes.iter().all(|p| !p.is_running()) {
158                break;
159            }
160            std::thread::sleep(std::time::Duration::from_millis(100));
161        }
162
163        // Force kill any remaining
164        for proc in &processes {
165            if proc.is_running() {
166                if let Err(e) = proc.kill() {
167                    printer.warning(&format!(
168                        "Failed to kill {} [PID {}]: {}",
169                        proc.name, proc.pid, e
170                    ));
171                    kill_failed.push(proc.pid);
172                }
173            }
174        }
175
176        // Poll until ports are free
177        let mut freed: Vec<u16> = Vec::new();
178        let mut still_busy: Vec<u16> = Vec::new();
179
180        let poll_start = std::time::Instant::now();
181        let poll_timeout = std::time::Duration::from_secs(self.wait);
182
183        let target_ports: Vec<u16> = port_processes.iter().map(|(port, _)| *port).collect();
184
185        loop {
186            let mut all_free = true;
187            freed.clear();
188            still_busy.clear();
189
190            for port in &target_ports {
191                match PortInfo::find_by_port(*port) {
192                    Ok(None) => freed.push(*port),
193                    _ => {
194                        still_busy.push(*port);
195                        all_free = false;
196                    }
197                }
198            }
199
200            if all_free || poll_start.elapsed() >= poll_timeout {
201                break;
202            }
203
204            std::thread::sleep(std::time::Duration::from_millis(250));
205        }
206
207        // Report results
208        if self.json {
209            let results: Vec<FreeResult> = freed
210                .iter()
211                .map(|p| FreeResult {
212                    port: *p,
213                    freed: true,
214                })
215                .chain(still_busy.iter().map(|p| FreeResult {
216                    port: *p,
217                    freed: false,
218                }))
219                .collect();
220
221            printer.print_json(&FreeOutput {
222                action: "free",
223                success: still_busy.is_empty(),
224                results,
225            });
226        } else {
227            for port in &freed {
228                println!(
229                    "{} Freed port {}",
230                    "✓".green().bold(),
231                    port.to_string().cyan().bold()
232                );
233            }
234            for port in &still_busy {
235                println!(
236                    "{} Port {} still in use (may be in TIME_WAIT)",
237                    "✗".red().bold(),
238                    port.to_string().cyan()
239                );
240            }
241        }
242
243        if still_busy.is_empty() {
244            Ok(())
245        } else {
246            Err(ProcError::reported(
247                ExitCode::GeneralError,
248                format!(
249                    "{} port{} still in use",
250                    still_busy.len(),
251                    if still_busy.len() == 1 { "" } else { "s" }
252                ),
253            ))
254        }
255    }
256}
257
258#[derive(Serialize)]
259struct FreeOutput {
260    action: &'static str,
261    success: bool,
262    results: Vec<FreeResult>,
263}
264
265#[derive(Serialize)]
266struct FreeResult {
267    port: u16,
268    freed: bool,
269}