Skip to main content

systemprompt_loader/subprocess/
ports.rs

1//! Which processes are listening on a TCP port.
2//!
3//! The lookup shells out (`lsof` on Unix, `netstat` on Windows) and therefore
4//! blocks; [`super::pids_listening_on`] runs it under `spawn_blocking`. Only
5//! listening sockets count: a client connection to the port is not a holder.
6//! Port 0 means "no port" — asking the OS for its holder would match every
7//! unbound socket — so it never reaches the lookup, and a reported pid 0 is
8//! dropped.
9//! The parsers are pure so their edge cases are testable without a socket.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use std::process::Command;
15
16use super::SupervisionError;
17
18#[must_use]
19pub fn parse_lsof_pids(stdout: &str) -> Vec<u32> {
20    let mut pids: Vec<u32> = stdout
21        .lines()
22        .filter_map(|line| line.trim().parse().ok())
23        .filter(|pid| *pid != 0)
24        .collect();
25    pids.sort_unstable();
26    pids.dedup();
27    pids
28}
29
30#[must_use]
31pub fn parse_netstat_listeners(stdout: &str, port: u16) -> Vec<u32> {
32    let suffix = format!(":{port}");
33    let mut pids: Vec<u32> = stdout
34        .lines()
35        .filter_map(|line| {
36            let fields: Vec<&str> = line.split_whitespace().collect();
37            match fields.as_slice() {
38                [proto, local, _remote, state, pid]
39                    if proto.eq_ignore_ascii_case("TCP")
40                        && local.ends_with(&suffix)
41                        && state.eq_ignore_ascii_case("LISTENING") =>
42                {
43                    pid.parse().ok()
44                },
45                _ => None,
46            }
47        })
48        .filter(|pid| *pid != 0)
49        .collect();
50    pids.sort_unstable();
51    pids.dedup();
52    pids
53}
54
55#[cfg(unix)]
56pub(super) fn listening_pids(port: u16) -> Result<Vec<u32>, SupervisionError> {
57    let output = Command::new("lsof")
58        .args(["-nP", "-t", &format!("-iTCP:{port}"), "-sTCP:LISTEN"])
59        .output()
60        .map_err(|source| SupervisionError::Tool {
61            tool: "lsof",
62            source,
63        })?;
64    // Why: lsof exits 1 with empty output when nothing matches the filter.
65    if !output.status.success() && !output.stdout.is_empty() {
66        return Err(SupervisionError::ToolFailed {
67            tool: "lsof",
68            status: output.status,
69        });
70    }
71    Ok(parse_lsof_pids(&String::from_utf8_lossy(&output.stdout)))
72}
73
74#[cfg(windows)]
75pub(super) fn listening_pids(port: u16) -> Result<Vec<u32>, SupervisionError> {
76    let output = Command::new("netstat")
77        .args(["-ano", "-p", "TCP"])
78        .output()
79        .map_err(|source| SupervisionError::Tool {
80            tool: "netstat",
81            source,
82        })?;
83    if !output.status.success() {
84        return Err(SupervisionError::ToolFailed {
85            tool: "netstat",
86            status: output.status,
87        });
88    }
89    Ok(parse_netstat_listeners(
90        &String::from_utf8_lossy(&output.stdout),
91        port,
92    ))
93}
94
95#[cfg(not(any(unix, windows)))]
96pub(super) fn listening_pids(_port: u16) -> Result<Vec<u32>, SupervisionError> {
97    Err(SupervisionError::Unsupported)
98}