Skip to main content

killport/
killport.rs

1use crate::container::Container;
2use crate::killable::{Killable, KillableType};
3#[cfg(target_os = "linux")]
4use crate::linux::find_target_processes;
5#[cfg(target_os = "macos")]
6use crate::macos::find_target_processes;
7#[cfg(target_os = "windows")]
8use crate::windows::find_target_processes;
9use crate::{cli::Mode, signal::KillportSignal};
10use std::io::Error;
11use tokio::runtime::{Builder, Runtime};
12
13/// Trait for finding native processes on a port (enables mocking in tests).
14pub trait ProcessFinder {
15    fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error>;
16}
17
18/// Trait for container runtime operations (enables mocking in tests).
19pub trait ContainerOps {
20    fn is_available(&self) -> Result<bool, Error>;
21    fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error>;
22}
23
24/// Real implementation of ProcessFinder that calls the platform-specific functions.
25pub struct NativeProcessFinder;
26
27impl ProcessFinder for NativeProcessFinder {
28    fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error> {
29        let processes = find_target_processes(port)?;
30        Ok(processes
31            .into_iter()
32            .map(|p| Box::new(p) as Box<dyn Killable>)
33            .collect())
34    }
35}
36
37/// Real implementation of ContainerOps that calls the container runtime API.
38pub struct RealContainerOps {
39    rt: Runtime,
40}
41
42impl RealContainerOps {
43    pub fn new() -> Result<Self, Error> {
44        Ok(Self {
45            rt: Builder::new_current_thread().enable_all().build()?,
46        })
47    }
48}
49
50impl ContainerOps for RealContainerOps {
51    fn is_available(&self) -> Result<bool, Error> {
52        Container::is_available(&self.rt)
53    }
54
55    fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error> {
56        Container::find_target_containers(&self.rt, port)
57    }
58}
59
60/// Killport implementation with injectable dependencies for testability.
61pub struct Killport<P: ProcessFinder, D: ContainerOps> {
62    process_finder: P,
63    container_ops: D,
64}
65
66impl Killport<NativeProcessFinder, RealContainerOps> {
67    /// Creates a Killport with real (production) dependencies.
68    pub fn with_real_deps() -> Result<Self, Error> {
69        Ok(Self::new(NativeProcessFinder, RealContainerOps::new()?))
70    }
71}
72
73impl<P: ProcessFinder, D: ContainerOps> Killport<P, D> {
74    pub fn new(process_finder: P, container_ops: D) -> Self {
75        Self {
76            process_finder,
77            container_ops,
78        }
79    }
80
81    pub fn find_target_killables(
82        &self,
83        port: u16,
84        mode: Mode,
85    ) -> Result<Vec<Box<dyn Killable>>, Error> {
86        let mut target_killables: Vec<Box<dyn Killable>> = vec![];
87        let containers_available = mode != Mode::Process && self.container_ops.is_available()?;
88
89        // Find containers first — if any are found, native processes on the same port
90        // are port forwarders (docker-proxy, OrbStack Helper, etc.) and must be skipped.
91        let has_containers = if containers_available && mode != Mode::Process {
92            let target_containers = self.container_ops.find_target_containers(port)?;
93            let found = !target_containers.is_empty();
94            for container in target_containers {
95                target_killables.push(Box::new(container));
96            }
97            found
98        } else {
99            false
100        };
101
102        if mode != Mode::Container {
103            // Skip native processes when containers own the port — those processes
104            // are port forwarders (docker-proxy, OrbStack Helper, Podman, etc.)
105            if !has_containers {
106                let target_processes = self.process_finder.find_target_processes(port)?;
107                for process in target_processes {
108                    target_killables.push(process);
109                }
110            }
111        }
112
113        Ok(target_killables)
114    }
115
116    pub fn kill_service_by_port(
117        &self,
118        port: u16,
119        signal: KillportSignal,
120        mode: Mode,
121        dry_run: bool,
122    ) -> Result<Vec<(KillableType, String)>, Error> {
123        let mut results = Vec::new();
124        let target_killables = self.find_target_killables(port, mode)?;
125
126        for killable in target_killables {
127            let killed = dry_run || killable.kill(signal.clone())?;
128            if killed {
129                results.push((killable.get_type(), killable.get_name()));
130            }
131        }
132
133        Ok(results)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    #[cfg(unix)]
141    use nix::sys::signal::Signal;
142    use std::cell::RefCell;
143
144    // ─── Mock implementations for testing orchestration logic ────────────
145
146    struct MockKillable {
147        kill_result: Result<bool, Error>,
148        killable_type: KillableType,
149        name: String,
150        kill_called: RefCell<bool>,
151    }
152
153    impl MockKillable {
154        fn process(name: &str) -> Self {
155            Self {
156                kill_result: Ok(true),
157                killable_type: KillableType::Process,
158                name: name.to_string(),
159                kill_called: RefCell::new(false),
160            }
161        }
162
163        fn with_kill_result(mut self, result: Result<bool, Error>) -> Self {
164            self.kill_result = result;
165            self
166        }
167    }
168
169    impl Killable for MockKillable {
170        fn kill(&self, _signal: KillportSignal) -> Result<bool, Error> {
171            *self.kill_called.borrow_mut() = true;
172            match &self.kill_result {
173                Ok(v) => Ok(*v),
174                Err(e) => Err(Error::new(e.kind(), e.to_string())),
175            }
176        }
177
178        fn get_type(&self) -> KillableType {
179            self.killable_type.clone()
180        }
181
182        fn get_name(&self) -> String {
183            self.name.clone()
184        }
185    }
186
187    struct FnProcessFinder<F: Fn(u16) -> Result<Vec<Box<dyn Killable>>, Error>> {
188        finder: F,
189    }
190
191    impl<F: Fn(u16) -> Result<Vec<Box<dyn Killable>>, Error>> ProcessFinder for FnProcessFinder<F> {
192        fn find_target_processes(&self, port: u16) -> Result<Vec<Box<dyn Killable>>, Error> {
193            (self.finder)(port)
194        }
195    }
196
197    struct FnContainerOps<
198        P: Fn() -> Result<bool, Error>,
199        C: Fn(u16) -> Result<Vec<Container>, Error>,
200    > {
201        is_present: P,
202        find_containers: C,
203    }
204
205    impl<P: Fn() -> Result<bool, Error>, C: Fn(u16) -> Result<Vec<Container>, Error>> ContainerOps
206        for FnContainerOps<P, C>
207    {
208        fn is_available(&self) -> Result<bool, Error> {
209            (self.is_present)()
210        }
211
212        fn find_target_containers(&self, port: u16) -> Result<Vec<Container>, Error> {
213            (self.find_containers)(port)
214        }
215    }
216
217    #[allow(clippy::type_complexity)]
218    fn no_containers() -> FnContainerOps<
219        impl Fn() -> Result<bool, Error>,
220        impl Fn(u16) -> Result<Vec<Container>, Error>,
221    > {
222        FnContainerOps {
223            is_present: || Ok(false),
224            find_containers: |_| Ok(vec![]),
225        }
226    }
227
228    #[allow(clippy::type_complexity)]
229    fn with_containers(
230        containers: Vec<String>,
231    ) -> FnContainerOps<
232        impl Fn() -> Result<bool, Error>,
233        impl Fn(u16) -> Result<Vec<Container>, Error>,
234    > {
235        FnContainerOps {
236            is_present: || Ok(true),
237            find_containers: move |_| {
238                Ok(containers
239                    .iter()
240                    .map(|n| Container { name: n.clone() })
241                    .collect())
242            },
243        }
244    }
245
246    fn signal() -> KillportSignal {
247        #[cfg(unix)]
248        {
249            KillportSignal(Signal::SIGKILL)
250        }
251        #[cfg(not(unix))]
252        {
253            KillportSignal("SIGKILL".to_string())
254        }
255    }
256
257    // ─── Orchestration Tests: find_target_killables ──────────────────────
258
259    #[test]
260    fn test_find_killables_mode_auto_no_containers() {
261        let finder = FnProcessFinder {
262            finder: |_| {
263                let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
264                Ok(vec![p])
265            },
266        };
267        let kp = Killport::new(finder, no_containers());
268        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
269        assert_eq!(results.len(), 1);
270        assert_eq!(results[0].get_type(), KillableType::Process);
271        assert_eq!(results[0].get_name(), "my_app");
272    }
273
274    #[test]
275    fn test_find_killables_containers_found_skips_all_processes() {
276        // When containers are found on a port, ALL native processes are skipped
277        // because they are port forwarders (docker-proxy, OrbStack Helper, etc.)
278        let finder = FnProcessFinder {
279            finder: |_| {
280                let p: Box<dyn Killable> = Box::new(MockKillable::process("OrbStack Helper"));
281                Ok(vec![p])
282            },
283        };
284        let ct = with_containers(vec!["nginx".to_string()]);
285        let kp = Killport::new(finder, ct);
286        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
287        // Only the container — native process is a port forwarder and must be skipped
288        assert_eq!(results.len(), 1);
289        assert_eq!(results[0].get_type(), KillableType::Container);
290        assert_eq!(results[0].get_name(), "nginx");
291    }
292
293    #[test]
294    fn test_find_killables_no_containers_keeps_all_processes() {
295        // When no containers are found, all native processes are returned
296        // regardless of their name (docker-proxy, orbstack, etc.)
297        let finder = FnProcessFinder {
298            finder: |_| {
299                let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
300                let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
301                Ok(vec![p1, p2])
302            },
303        };
304        let ct = with_containers(vec![]); // runtime present, no containers
305        let kp = Killport::new(finder, ct);
306        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
307        // Both returned — no containers means these are real targets
308        assert_eq!(results.len(), 2);
309        assert_eq!(results[0].get_name(), "docker-proxy");
310        assert_eq!(results[1].get_name(), "my_app");
311    }
312
313    #[test]
314    fn test_find_killables_runtime_absent_returns_all_processes() {
315        // When container runtime is NOT present, all processes are returned
316        let finder = FnProcessFinder {
317            finder: |_| {
318                let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
319                let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
320                Ok(vec![p1, p2])
321            },
322        };
323        let kp = Killport::new(finder, no_containers());
324        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
325        assert_eq!(results.len(), 2);
326        assert_eq!(results[0].get_name(), "docker-proxy");
327        assert_eq!(results[1].get_name(), "my_app");
328    }
329
330    #[test]
331    fn test_find_killables_process_mode_skips_containers() {
332        // In Process mode, container runtime is never checked — all processes returned
333        let finder = FnProcessFinder {
334            finder: |_| {
335                let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
336                let p2: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
337                Ok(vec![p1, p2])
338            },
339        };
340        let ct = FnContainerOps {
341            is_present: || panic!("Container runtime should not be checked in Process mode"),
342            find_containers: |_| panic!("Container runtime should not be checked in Process mode"),
343        };
344        let kp = Killport::new(finder, ct);
345        let results = kp.find_target_killables(8080, Mode::Process).unwrap();
346        assert_eq!(results.len(), 2);
347        assert_eq!(results[0].get_name(), "docker-proxy");
348        assert_eq!(results[1].get_name(), "my_app");
349    }
350
351    #[test]
352    fn test_find_killables_multiple_containers_skips_processes() {
353        // Multiple port forwarder processes should all be skipped
354        let finder = FnProcessFinder {
355            finder: |_| {
356                let p1: Box<dyn Killable> = Box::new(MockKillable::process("docker-proxy"));
357                let p2: Box<dyn Killable> = Box::new(MockKillable::process("dockerd"));
358                Ok(vec![p1, p2])
359            },
360        };
361        let ct = with_containers(vec!["nginx".to_string()]);
362        let kp = Killport::new(finder, ct);
363        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
364        assert_eq!(results.len(), 1);
365        assert_eq!(results[0].get_type(), KillableType::Container);
366        assert_eq!(results[0].get_name(), "nginx");
367    }
368
369    #[test]
370    fn test_find_killables_mode_process_only() {
371        let finder = FnProcessFinder {
372            finder: |_| {
373                let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
374                Ok(vec![p])
375            },
376        };
377        // Container runtime should never be checked in Process mode
378        let ct = FnContainerOps {
379            is_present: || panic!("Container runtime should not be checked in Process mode"),
380            find_containers: |_| panic!("Container runtime should not be checked in Process mode"),
381        };
382        let kp = Killport::new(finder, ct);
383        let results = kp.find_target_killables(8080, Mode::Process).unwrap();
384        assert_eq!(results.len(), 1);
385        assert_eq!(results[0].get_type(), KillableType::Process);
386    }
387
388    #[test]
389    fn test_find_killables_mode_container_only() {
390        let finder = FnProcessFinder {
391            finder: |_| panic!("Process finder should not be called in Container mode"),
392        };
393        let ct = with_containers(vec!["redis".to_string()]);
394        let kp = Killport::new(finder, ct);
395        let results = kp.find_target_killables(8080, Mode::Container).unwrap();
396        assert_eq!(results.len(), 1);
397        assert_eq!(results[0].get_type(), KillableType::Container);
398        assert_eq!(results[0].get_name(), "redis");
399    }
400
401    #[test]
402    fn test_find_killables_empty_results() {
403        let finder = FnProcessFinder {
404            finder: |_| Ok(vec![]),
405        };
406        let kp = Killport::new(finder, no_containers());
407        let results = kp.find_target_killables(8080, Mode::Auto).unwrap();
408        assert!(results.is_empty());
409    }
410
411    #[test]
412    fn test_find_killables_process_finder_error() {
413        let finder = FnProcessFinder {
414            finder: |_| {
415                Err(Error::new(
416                    std::io::ErrorKind::PermissionDenied,
417                    "access denied",
418                ))
419            },
420        };
421        let kp = Killport::new(finder, no_containers());
422        let result = kp.find_target_killables(8080, Mode::Auto);
423        assert!(result.is_err());
424        let err = result.err().unwrap();
425        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
426    }
427
428    #[test]
429    fn test_find_killables_container_check_error() {
430        let finder = FnProcessFinder {
431            finder: |_| Ok(vec![]),
432        };
433        let ct = FnContainerOps {
434            is_present: || Err(Error::other("container runtime error")),
435            find_containers: |_| Ok(vec![]),
436        };
437        let kp = Killport::new(finder, ct);
438        let result = kp.find_target_killables(8080, Mode::Auto);
439        assert!(result.is_err());
440    }
441
442    #[test]
443    fn test_find_killables_container_find_error() {
444        let finder = FnProcessFinder {
445            finder: |_| Ok(vec![]),
446        };
447        let ct = FnContainerOps {
448            is_present: || Ok(true),
449            find_containers: |_| Err(Error::other("container error")),
450        };
451        let kp = Killport::new(finder, ct);
452        let result = kp.find_target_killables(8080, Mode::Auto);
453        assert!(result.is_err());
454    }
455
456    // ─── Orchestration Tests: kill_service_by_port ───────────────────────
457
458    #[test]
459    fn test_kill_service_actual_kill_succeeds() {
460        let finder = FnProcessFinder {
461            finder: |_| {
462                let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
463                Ok(vec![p])
464            },
465        };
466        let kp = Killport::new(finder, no_containers());
467        let results = kp
468            .kill_service_by_port(8080, signal(), Mode::Auto, false)
469            .unwrap();
470        assert_eq!(results.len(), 1);
471        assert_eq!(results[0].0, KillableType::Process);
472        assert_eq!(results[0].1, "my_app");
473    }
474
475    #[test]
476    fn test_kill_service_kill_returns_false() {
477        let finder = FnProcessFinder {
478            finder: |_| {
479                let p: Box<dyn Killable> =
480                    Box::new(MockKillable::process("my_app").with_kill_result(Ok(false)));
481                Ok(vec![p])
482            },
483        };
484        let kp = Killport::new(finder, no_containers());
485        let results = kp
486            .kill_service_by_port(8080, signal(), Mode::Auto, false)
487            .unwrap();
488        assert!(
489            results.is_empty(),
490            "Process that returned false should not be in results"
491        );
492    }
493
494    #[test]
495    fn test_kill_service_kill_error_propagates() {
496        let finder =
497            FnProcessFinder {
498                finder: |_| {
499                    let p: Box<dyn Killable> =
500                        Box::new(MockKillable::process("my_app").with_kill_result(Err(
501                            Error::new(std::io::ErrorKind::PermissionDenied, "EPERM"),
502                        )));
503                    Ok(vec![p])
504                },
505            };
506        let kp = Killport::new(finder, no_containers());
507        let result = kp.kill_service_by_port(8080, signal(), Mode::Auto, false);
508        assert!(result.is_err());
509    }
510
511    #[test]
512    fn test_kill_service_dry_run_collects_without_killing() {
513        // We can't directly check kill_called on the mock since it's moved,
514        // but we can verify the results are collected and the names match
515        let finder = FnProcessFinder {
516            finder: |_| {
517                let p: Box<dyn Killable> = Box::new(MockKillable::process("my_app"));
518                Ok(vec![p])
519            },
520        };
521        let kp = Killport::new(finder, no_containers());
522        let results = kp
523            .kill_service_by_port(8080, signal(), Mode::Auto, true)
524            .unwrap();
525        assert_eq!(results.len(), 1);
526        assert_eq!(results[0].0, KillableType::Process);
527        assert_eq!(results[0].1, "my_app");
528    }
529
530    #[test]
531    fn test_kill_service_dry_run_empty() {
532        let finder = FnProcessFinder {
533            finder: |_| Ok(vec![]),
534        };
535        let kp = Killport::new(finder, no_containers());
536        let results = kp
537            .kill_service_by_port(8080, signal(), Mode::Auto, true)
538            .unwrap();
539        assert!(results.is_empty());
540    }
541
542    #[test]
543    fn test_kill_service_multiple_targets_no_containers() {
544        // Multiple processes, no containers — all get killed
545        let finder = FnProcessFinder {
546            finder: |_| {
547                let p1: Box<dyn Killable> = Box::new(MockKillable::process("app1"));
548                let p2: Box<dyn Killable> = Box::new(MockKillable::process("app2"));
549                Ok(vec![p1, p2])
550            },
551        };
552        let kp = Killport::new(finder, no_containers());
553        let results = kp
554            .kill_service_by_port(8080, signal(), Mode::Auto, true)
555            .unwrap();
556        assert_eq!(results.len(), 2);
557        assert_eq!(results[0].1, "app1");
558        assert_eq!(results[1].1, "app2");
559    }
560
561    #[test]
562    fn test_kill_service_container_found_skips_processes() {
563        // When a container is found, only the container is killed (processes are port forwarders)
564        let finder = FnProcessFinder {
565            finder: |_| {
566                let p: Box<dyn Killable> = Box::new(MockKillable::process("OrbStack Helper"));
567                Ok(vec![p])
568            },
569        };
570        let ct = with_containers(vec!["nginx".to_string()]);
571        let kp = Killport::new(finder, ct);
572        let results = kp
573            .kill_service_by_port(8080, signal(), Mode::Auto, true)
574            .unwrap();
575        assert_eq!(results.len(), 1);
576        assert_eq!(results[0].0, KillableType::Container);
577        assert_eq!(results[0].1, "nginx");
578    }
579}